apache-airflow-providers-fab 3.1.0rc1__py3-none-any.whl → 3.2.0rc1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (52) hide show
  1. airflow/providers/fab/__init__.py +1 -1
  2. airflow/providers/fab/auth_manager/api_endpoints/user_endpoint.py +3 -1
  3. airflow/providers/fab/auth_manager/api_fastapi/datamodels/roles.py +13 -7
  4. airflow/providers/fab/auth_manager/api_fastapi/datamodels/users.py +68 -0
  5. airflow/providers/fab/auth_manager/api_fastapi/openapi/v2-fab-auth-manager-generated.yaml +485 -18
  6. airflow/providers/fab/auth_manager/api_fastapi/routes/login.py +2 -4
  7. airflow/providers/fab/auth_manager/api_fastapi/routes/users.py +133 -0
  8. airflow/providers/fab/auth_manager/api_fastapi/services/login.py +1 -2
  9. airflow/providers/fab/auth_manager/api_fastapi/services/users.py +219 -0
  10. airflow/providers/fab/auth_manager/cli_commands/db_command.py +2 -2
  11. airflow/providers/fab/auth_manager/cli_commands/permissions_command.py +6 -2
  12. airflow/providers/fab/auth_manager/cli_commands/user_command.py +3 -3
  13. airflow/providers/fab/auth_manager/fab_auth_manager.py +18 -51
  14. airflow/providers/fab/auth_manager/models/__init__.py +6 -6
  15. airflow/providers/fab/auth_manager/security_manager/override.py +97 -84
  16. airflow/providers/fab/auth_manager/views/user.py +12 -0
  17. airflow/providers/fab/cli/__init__.py +18 -0
  18. airflow/providers/fab/{auth_manager/cli_commands → cli}/definition.py +50 -2
  19. airflow/providers/fab/get_provider_info.py +8 -0
  20. airflow/providers/fab/version_compat.py +1 -0
  21. airflow/providers/fab/www/app.py +2 -7
  22. airflow/providers/fab/www/extensions/init_appbuilder.py +3 -2
  23. airflow/providers/fab/www/extensions/init_views.py +11 -7
  24. airflow/providers/fab/www/package-lock.json +764 -572
  25. airflow/providers/fab/www/package.json +12 -9
  26. airflow/providers/fab/www/static/dist/{743.0c0bf201ae17e66a9a3f.js → 743.8fb7d21632ed892227fe.js} +2 -2
  27. airflow/providers/fab/www/static/dist/{airflowDefaultTheme.ef6fc04c9b6920cd75c9.js → airflowDefaultTheme.51e5d14856ee1ebc83ca.js} +1 -1
  28. airflow/providers/fab/www/static/dist/{flash.eaaf777ec1b3628cf7be.js → flash.865b6940c00b2a9041b3.js} +1 -1
  29. airflow/providers/fab/www/static/dist/{loadingDots.76f4332c0a932c3dc08f.js → loadingDots.07f5b9805847242736e1.js} +1 -1
  30. airflow/providers/fab/www/static/dist/main.8cffe40bcf7cca998f4e.js +2 -0
  31. airflow/providers/fab/www/static/dist/manifest.json +13 -13
  32. airflow/providers/fab/www/static/dist/{materialIcons.ad07a489b2f0fc1a96bf.js → materialIcons.4fe84ae36604d84dec78.js} +1 -1
  33. airflow/providers/fab/www/static/dist/moment.0ec3ee3fb60dc999b1fd.js +1 -0
  34. airflow/providers/fab/www/static/js/main.js +11 -0
  35. airflow/providers/fab/www/templates/airflow/main.html +1 -0
  36. {apache_airflow_providers_fab-3.1.0rc1.dist-info → apache_airflow_providers_fab-3.2.0rc1.dist-info}/METADATA +10 -10
  37. {apache_airflow_providers_fab-3.1.0rc1.dist-info → apache_airflow_providers_fab-3.2.0rc1.dist-info}/RECORD +50 -46
  38. {apache_airflow_providers_fab-3.1.0rc1.dist-info → apache_airflow_providers_fab-3.2.0rc1.dist-info}/licenses/3rd-party-licenses/LICENSES-ui.txt +1 -1
  39. {apache_airflow_providers_fab-3.1.0rc1.dist-info → apache_airflow_providers_fab-3.2.0rc1.dist-info}/licenses/NOTICE +1 -1
  40. airflow/providers/fab/www/static/dist/main.bc1f701c3d133e2a3bab.js +0 -2
  41. airflow/providers/fab/www/static/dist/moment.5b85b4f6be2fe9c405ac.js +0 -1
  42. /airflow/providers/fab/www/static/dist/{743.0c0bf201ae17e66a9a3f.js.LICENSE.txt → 743.8fb7d21632ed892227fe.js.LICENSE.txt} +0 -0
  43. /airflow/providers/fab/www/static/dist/{airflowDefaultTheme.ef6fc04c9b6920cd75c9.css → airflowDefaultTheme.51e5d14856ee1ebc83ca.css} +0 -0
  44. /airflow/providers/fab/www/static/dist/{flash.eaaf777ec1b3628cf7be.css → flash.865b6940c00b2a9041b3.css} +0 -0
  45. /airflow/providers/fab/www/static/dist/{loadingDots.76f4332c0a932c3dc08f.css → loadingDots.07f5b9805847242736e1.css} +0 -0
  46. /airflow/providers/fab/www/static/dist/{main.bc1f701c3d133e2a3bab.css → main.8cffe40bcf7cca998f4e.css} +0 -0
  47. /airflow/providers/fab/www/static/dist/{main.bc1f701c3d133e2a3bab.js.LICENSE.txt → main.8cffe40bcf7cca998f4e.js.LICENSE.txt} +0 -0
  48. /airflow/providers/fab/www/static/dist/{materialIcons.ad07a489b2f0fc1a96bf.css → materialIcons.4fe84ae36604d84dec78.css} +0 -0
  49. /airflow/providers/fab/www/static/dist/{runtime.254c277d91ce3ac79c64.js → runtime.45b36fb8335446865b53.js} +0 -0
  50. {apache_airflow_providers_fab-3.1.0rc1.dist-info → apache_airflow_providers_fab-3.2.0rc1.dist-info}/WHEEL +0 -0
  51. {apache_airflow_providers_fab-3.1.0rc1.dist-info → apache_airflow_providers_fab-3.2.0rc1.dist-info}/entry_points.txt +0 -0
  52. {apache_airflow_providers_fab-3.1.0rc1.dist-info → apache_airflow_providers_fab-3.2.0rc1.dist-info}/licenses/LICENSE +0 -0
@@ -0,0 +1,133 @@
1
+ # Licensed to the Apache Software Foundation (ASF) under one
2
+ # or more contributor license agreements. See the NOTICE file
3
+ # distributed with this work for additional information
4
+ # regarding copyright ownership. The ASF licenses this file
5
+ # to you under the Apache License, Version 2.0 (the
6
+ # "License"); you may not use this file except in compliance
7
+ # with the License. You may obtain a copy of the License at
8
+ #
9
+ # http://www.apache.org/licenses/LICENSE-2.0
10
+ #
11
+ # Unless required by applicable law or agreed to in writing,
12
+ # software distributed under the License is distributed on an
13
+ # "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
14
+ # KIND, either express or implied. See the License for the
15
+ # specific language governing permissions and limitations
16
+ # under the License.
17
+ from __future__ import annotations
18
+
19
+ from fastapi import Depends, Path, Query, status
20
+
21
+ from airflow.api_fastapi.common.router import AirflowRouter
22
+ from airflow.api_fastapi.core_api.openapi.exceptions import create_openapi_http_exception_doc
23
+ from airflow.providers.fab.auth_manager.api_fastapi.datamodels.users import (
24
+ UserBody,
25
+ UserCollectionResponse,
26
+ UserPatchBody,
27
+ UserResponse,
28
+ )
29
+ from airflow.providers.fab.auth_manager.api_fastapi.parameters import get_effective_limit
30
+ from airflow.providers.fab.auth_manager.api_fastapi.security import requires_fab_custom_view
31
+ from airflow.providers.fab.auth_manager.api_fastapi.services.users import FABAuthManagerUsers
32
+ from airflow.providers.fab.auth_manager.cli_commands.utils import get_application_builder
33
+ from airflow.providers.fab.www.security import permissions
34
+
35
+ users_router = AirflowRouter(prefix="/fab/v1", tags=["FabAuthManager"])
36
+
37
+
38
+ @users_router.post(
39
+ "/users",
40
+ responses=create_openapi_http_exception_doc(
41
+ [
42
+ status.HTTP_400_BAD_REQUEST,
43
+ status.HTTP_401_UNAUTHORIZED,
44
+ status.HTTP_403_FORBIDDEN,
45
+ status.HTTP_409_CONFLICT,
46
+ status.HTTP_500_INTERNAL_SERVER_ERROR,
47
+ ]
48
+ ),
49
+ dependencies=[Depends(requires_fab_custom_view("POST", permissions.RESOURCE_USER))],
50
+ )
51
+ def create_user(body: UserBody) -> UserResponse:
52
+ with get_application_builder():
53
+ return FABAuthManagerUsers.create_user(body=body)
54
+
55
+
56
+ @users_router.get(
57
+ "/users",
58
+ response_model=UserCollectionResponse,
59
+ responses=create_openapi_http_exception_doc(
60
+ [
61
+ status.HTTP_400_BAD_REQUEST,
62
+ status.HTTP_401_UNAUTHORIZED,
63
+ status.HTTP_403_FORBIDDEN,
64
+ ]
65
+ ),
66
+ dependencies=[Depends(requires_fab_custom_view("GET", permissions.RESOURCE_USER))],
67
+ )
68
+ def get_users(
69
+ order_by: str = Query("id", description="Field to order by. Prefix with '-' for descending."),
70
+ limit: int = Depends(get_effective_limit()),
71
+ offset: int = Query(0, ge=0, description="Number of items to skip before starting to collect results."),
72
+ ) -> UserCollectionResponse:
73
+ """List users with pagination and ordering."""
74
+ with get_application_builder():
75
+ return FABAuthManagerUsers.get_users(order_by=order_by, limit=limit, offset=offset)
76
+
77
+
78
+ @users_router.get(
79
+ "/users/{username}",
80
+ responses=create_openapi_http_exception_doc(
81
+ [
82
+ status.HTTP_401_UNAUTHORIZED,
83
+ status.HTTP_403_FORBIDDEN,
84
+ status.HTTP_404_NOT_FOUND,
85
+ ]
86
+ ),
87
+ dependencies=[Depends(requires_fab_custom_view("GET", permissions.RESOURCE_USER))],
88
+ )
89
+ def get_user(username: str = Path(..., min_length=1)) -> UserResponse:
90
+ """Get a user by username."""
91
+ with get_application_builder():
92
+ return FABAuthManagerUsers.get_user(username=username)
93
+
94
+
95
+ @users_router.patch(
96
+ "/users/{username}",
97
+ responses=create_openapi_http_exception_doc(
98
+ [
99
+ status.HTTP_400_BAD_REQUEST,
100
+ status.HTTP_401_UNAUTHORIZED,
101
+ status.HTTP_403_FORBIDDEN,
102
+ status.HTTP_404_NOT_FOUND,
103
+ status.HTTP_409_CONFLICT,
104
+ ]
105
+ ),
106
+ dependencies=[Depends(requires_fab_custom_view("PUT", permissions.RESOURCE_USER))],
107
+ )
108
+ def update_user(
109
+ body: UserPatchBody,
110
+ username: str = Path(..., min_length=1),
111
+ update_mask: str | None = Query(None, description="Comma-separated list of fields to update"),
112
+ ) -> UserResponse:
113
+ """Update an existing user."""
114
+ with get_application_builder():
115
+ return FABAuthManagerUsers.update_user(username=username, body=body, update_mask=update_mask)
116
+
117
+
118
+ @users_router.delete(
119
+ "/users/{username}",
120
+ status_code=status.HTTP_204_NO_CONTENT,
121
+ responses=create_openapi_http_exception_doc(
122
+ [
123
+ status.HTTP_401_UNAUTHORIZED,
124
+ status.HTTP_403_FORBIDDEN,
125
+ status.HTTP_404_NOT_FOUND,
126
+ ]
127
+ ),
128
+ dependencies=[Depends(requires_fab_custom_view("DELETE", permissions.RESOURCE_USER))],
129
+ )
130
+ def delete_user(username: str = Path(..., min_length=1)):
131
+ """Delete a user by username."""
132
+ with get_application_builder():
133
+ FABAuthManagerUsers.delete_user(username=username)
@@ -18,8 +18,7 @@ from __future__ import annotations
18
18
 
19
19
  from typing import Any
20
20
 
21
- from starlette import status
22
- from starlette.exceptions import HTTPException
21
+ from fastapi import HTTPException, status
23
22
 
24
23
  from airflow.configuration import conf
25
24
  from airflow.providers.fab.auth_manager.api_fastapi.datamodels.login import LoginResponse
@@ -0,0 +1,219 @@
1
+ # Licensed to the Apache Software Foundation (ASF) under one
2
+ # or more contributor license agreements. See the NOTICE file
3
+ # distributed with this work for additional information
4
+ # regarding copyright ownership. The ASF licenses this file
5
+ # to you under the Apache License, Version 2.0 (the
6
+ # "License"); you may not use this file except in compliance
7
+ # with the License. You may obtain a copy of the License at
8
+ #
9
+ # http://www.apache.org/licenses/LICENSE-2.0
10
+ #
11
+ # Unless required by applicable law or agreed to in writing,
12
+ # software distributed under the License is distributed on an
13
+ # "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
14
+ # KIND, either express or implied. See the License for the
15
+ # specific language governing permissions and limitations
16
+ # under the License.
17
+ from __future__ import annotations
18
+
19
+ from fastapi import HTTPException, status
20
+ from sqlalchemy import func, select
21
+ from werkzeug.security import generate_password_hash
22
+
23
+ from airflow.providers.fab.auth_manager.api_fastapi.datamodels.roles import Role
24
+ from airflow.providers.fab.auth_manager.api_fastapi.datamodels.users import (
25
+ UserBody,
26
+ UserCollectionResponse,
27
+ UserPatchBody,
28
+ UserResponse,
29
+ )
30
+ from airflow.providers.fab.auth_manager.api_fastapi.sorting import build_ordering
31
+ from airflow.providers.fab.auth_manager.models import User
32
+ from airflow.providers.fab.auth_manager.security_manager.override import FabAirflowSecurityManagerOverride
33
+ from airflow.providers.fab.www.utils import get_fab_auth_manager
34
+
35
+
36
+ class FABAuthManagerUsers:
37
+ """Service layer for FAB Auth Manager user operations."""
38
+
39
+ @staticmethod
40
+ def _resolve_roles(
41
+ sm: FabAirflowSecurityManagerOverride, role_refs: list[Role] | None
42
+ ) -> tuple[list, list[str]]:
43
+ seen = set()
44
+ roles: list = []
45
+ missing: list[str] = []
46
+ for r in role_refs or []:
47
+ if r.name in seen:
48
+ continue
49
+ seen.add(r.name)
50
+ role = sm.find_role(r.name)
51
+ (roles if role else missing).append(role or r.name)
52
+ return roles, missing
53
+
54
+ @classmethod
55
+ def get_user(cls, username: str) -> UserResponse:
56
+ """Get a user by username."""
57
+ security_manager = get_fab_auth_manager().security_manager
58
+ user = security_manager.find_user(username=username)
59
+ if not user:
60
+ raise HTTPException(
61
+ status_code=status.HTTP_404_NOT_FOUND,
62
+ detail=f"The User with username `{username}` was not found",
63
+ )
64
+ return UserResponse.model_validate(user)
65
+
66
+ @classmethod
67
+ def get_users(cls, *, order_by: str, limit: int, offset: int) -> UserCollectionResponse:
68
+ """Get users with pagination and ordering."""
69
+ security_manager = get_fab_auth_manager().security_manager
70
+ session = security_manager.session
71
+
72
+ total_entries = session.scalars(select(func.count(User.id))).one()
73
+
74
+ ordering = build_ordering(
75
+ order_by,
76
+ allowed={
77
+ "id": User.id,
78
+ "user_id": User.id,
79
+ "first_name": User.first_name,
80
+ "last_name": User.last_name,
81
+ "username": User.username,
82
+ "email": User.email,
83
+ "active": User.active,
84
+ },
85
+ )
86
+
87
+ stmt = select(User).order_by(ordering).offset(offset).limit(limit)
88
+ users = session.scalars(stmt).unique().all()
89
+
90
+ return UserCollectionResponse(
91
+ users=[UserResponse.model_validate(u) for u in users],
92
+ total_entries=total_entries,
93
+ )
94
+
95
+ @classmethod
96
+ def create_user(cls, body: UserBody) -> UserResponse:
97
+ security_manager = get_fab_auth_manager().security_manager
98
+
99
+ existing_username = security_manager.find_user(username=body.username)
100
+ if existing_username:
101
+ raise HTTPException(
102
+ status_code=status.HTTP_409_CONFLICT,
103
+ detail=f"Username `{body.username}` already exists. Use PATCH to update.",
104
+ )
105
+ existing_email = security_manager.find_user(email=body.email)
106
+ if existing_email:
107
+ raise HTTPException(
108
+ status_code=status.HTTP_409_CONFLICT,
109
+ detail=f"The email `{body.email}` is already taken.",
110
+ )
111
+
112
+ roles_to_add, missing_role_names = cls._resolve_roles(security_manager, body.roles)
113
+ if missing_role_names:
114
+ raise HTTPException(
115
+ status_code=status.HTTP_400_BAD_REQUEST,
116
+ detail=f"Unknown roles: {', '.join(repr(n) for n in missing_role_names)}",
117
+ )
118
+ if not roles_to_add:
119
+ default_role = security_manager.find_role(security_manager.auth_user_registration_role)
120
+ if default_role is None:
121
+ raise HTTPException(
122
+ status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
123
+ detail="Default registration role is not configured or not found.",
124
+ )
125
+ roles_to_add.append(default_role)
126
+
127
+ created = security_manager.add_user(
128
+ username=body.username,
129
+ email=body.email,
130
+ first_name=body.first_name,
131
+ last_name=body.last_name,
132
+ role=roles_to_add,
133
+ password=body.password.get_secret_value(),
134
+ )
135
+ if not created:
136
+ raise HTTPException(
137
+ status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
138
+ detail=f"Failed to add user `{body.username}`",
139
+ )
140
+ return UserResponse.model_validate(created)
141
+
142
+ @classmethod
143
+ def update_user(cls, username: str, body: UserPatchBody, update_mask: str | None = None) -> UserResponse:
144
+ """Update an existing user."""
145
+ security_manager = get_fab_auth_manager().security_manager
146
+
147
+ user = security_manager.find_user(username=username)
148
+ if user is None:
149
+ raise HTTPException(
150
+ status_code=status.HTTP_404_NOT_FOUND,
151
+ detail=f"The User with username `{username}` was not found",
152
+ )
153
+
154
+ if body.username is not None and body.username != username:
155
+ if security_manager.find_user(username=body.username):
156
+ raise HTTPException(
157
+ status_code=status.HTTP_409_CONFLICT,
158
+ detail=f"The username `{body.username}` already exists",
159
+ )
160
+
161
+ if body.email is not None and body.email != user.email:
162
+ if security_manager.find_user(email=body.email):
163
+ raise HTTPException(
164
+ status_code=status.HTTP_409_CONFLICT,
165
+ detail=f"The email `{body.email}` already exists",
166
+ )
167
+
168
+ all_fields = {"username", "email", "first_name", "last_name", "roles", "password"}
169
+
170
+ if update_mask is not None:
171
+ fields_to_update = {f.strip() for f in update_mask.split(",") if f.strip()}
172
+ invalid_fields = fields_to_update - all_fields
173
+ if invalid_fields:
174
+ raise HTTPException(
175
+ status_code=status.HTTP_400_BAD_REQUEST,
176
+ detail=f"Unknown update masks: {', '.join(repr(f) for f in invalid_fields)}",
177
+ )
178
+ else:
179
+ fields_to_update = all_fields
180
+
181
+ if "roles" in fields_to_update and body.roles is not None:
182
+ roles_to_update, missing_role_names = cls._resolve_roles(security_manager, body.roles)
183
+ if missing_role_names:
184
+ raise HTTPException(
185
+ status_code=status.HTTP_400_BAD_REQUEST,
186
+ detail=f"Unknown roles: {', '.join(repr(n) for n in missing_role_names)}",
187
+ )
188
+ user.roles = roles_to_update
189
+
190
+ if "password" in fields_to_update and body.password is not None:
191
+ user.password = generate_password_hash(body.password.get_secret_value())
192
+
193
+ if "username" in fields_to_update and body.username is not None:
194
+ user.username = body.username
195
+ if "email" in fields_to_update and body.email is not None:
196
+ user.email = body.email
197
+ if "first_name" in fields_to_update and body.first_name is not None:
198
+ user.first_name = body.first_name
199
+ if "last_name" in fields_to_update and body.last_name is not None:
200
+ user.last_name = body.last_name
201
+
202
+ security_manager.update_user(user)
203
+ return UserResponse.model_validate(user)
204
+
205
+ @classmethod
206
+ def delete_user(cls, username: str) -> None:
207
+ """Delete a user by username."""
208
+ security_manager = get_fab_auth_manager().security_manager
209
+
210
+ user = security_manager.find_user(username=username)
211
+ if user is None:
212
+ raise HTTPException(
213
+ status_code=status.HTTP_404_NOT_FOUND,
214
+ detail=f"The User with username `{username}` was not found",
215
+ )
216
+
217
+ user.roles = []
218
+ security_manager.session.delete(user)
219
+ security_manager.session.commit()
@@ -46,5 +46,5 @@ def migratedb(args):
46
46
  def downgrade(args):
47
47
  """Downgrades the metadata database."""
48
48
  session = settings.Session()
49
- dwongrade_command = FABDBManager(session).downgrade
50
- run_db_downgrade_command(args, dwongrade_command, revision_heads_map=_REVISION_HEADS_MAP)
49
+ downgrade_command = FABDBManager(session).downgrade
50
+ run_db_downgrade_command(args, downgrade_command, revision_heads_map=_REVISION_HEADS_MAP)
@@ -50,7 +50,11 @@ def cleanup_dag_permissions(dag_id: str, session: Session = NEW_SESSION) -> None
50
50
  from sqlalchemy import delete, select
51
51
 
52
52
  from airflow.providers.fab.auth_manager.models import Permission, Resource, assoc_permission_role
53
- from airflow.security.permissions import RESOURCE_DAG_PREFIX, RESOURCE_DAG_RUN, RESOURCE_DETAILS_MAP
53
+ from airflow.providers.fab.www.security.permissions import (
54
+ RESOURCE_DAG_PREFIX,
55
+ RESOURCE_DAG_RUN,
56
+ RESOURCE_DETAILS_MAP,
57
+ )
54
58
 
55
59
  # Clean up specific DAG permissions
56
60
  dag_resources = session.scalars(
@@ -107,7 +111,7 @@ def permissions_cleanup(args):
107
111
  from airflow.models import DagModel
108
112
  from airflow.providers.fab.auth_manager.cli_commands.utils import get_application_builder
109
113
  from airflow.providers.fab.auth_manager.models import Resource
110
- from airflow.security.permissions import (
114
+ from airflow.providers.fab.www.security.permissions import (
111
115
  RESOURCE_DAG_PREFIX,
112
116
  RESOURCE_DAG_RUN,
113
117
  RESOURCE_DETAILS_MAP,
@@ -73,7 +73,7 @@ def users_create(args):
73
73
  raise SystemExit(f"{args.role} is not a valid role. Valid roles are: {valid_roles}")
74
74
  password = _create_password(args)
75
75
  if appbuilder.sm.find_user(args.username):
76
- print(f"{args.username} already exist in the db")
76
+ print(f"{args.username} already exists in the db")
77
77
  return
78
78
  user = appbuilder.sm.add_user(
79
79
  args.username, args.firstname, args.lastname, args.email, role, password
@@ -101,7 +101,7 @@ def _find_user(args):
101
101
  @cli_utils.action_cli
102
102
  @providers_configuration_loaded
103
103
  def user_reset_password(args):
104
- """Reset user password user from DB."""
104
+ """Reset user password from DB."""
105
105
  user = _find_user(args)
106
106
  password = _create_password(args)
107
107
  with get_application_builder() as appbuilder:
@@ -143,7 +143,7 @@ def users_delete(args):
143
143
  @cli_utils.action_cli
144
144
  @providers_configuration_loaded
145
145
  def users_manage_role(args, remove=False):
146
- """Delete or appends user roles."""
146
+ """Delete or append user roles."""
147
147
  with get_application_builder() as appbuilder:
148
148
  user = _find_user(args)
149
149
  role = appbuilder.sm.find_role(args.role)
@@ -17,22 +17,20 @@
17
17
  # under the License.
18
18
  from __future__ import annotations
19
19
 
20
- import argparse
21
20
  from functools import cached_property
22
21
  from pathlib import Path
23
22
  from typing import TYPE_CHECKING, Any
24
23
  from urllib.parse import urljoin
25
24
 
26
- import packaging.version
25
+ from cachetools import TTLCache, cachedmethod
27
26
  from connexion import FlaskApi
28
27
  from fastapi import FastAPI
28
+ from fastapi.middleware.wsgi import WSGIMiddleware
29
29
  from flask import Blueprint, current_app, g
30
30
  from flask_appbuilder.const import AUTH_LDAP
31
31
  from sqlalchemy import select
32
32
  from sqlalchemy.orm import Session, joinedload
33
- from starlette.middleware.wsgi import WSGIMiddleware
34
33
 
35
- from airflow import __version__ as airflow_version
36
34
  from airflow.api_fastapi.app import AUTH_MANAGER_FASTAPI_APP_PREFIX
37
35
  from airflow.api_fastapi.auth.managers.base_auth_manager import BaseAuthManager
38
36
 
@@ -52,21 +50,10 @@ from airflow.api_fastapi.auth.managers.models.resource_details import (
52
50
  VariableDetails,
53
51
  )
54
52
  from airflow.api_fastapi.common.types import ExtraMenuItem, MenuItem
55
- from airflow.cli.cli_config import (
56
- DefaultHelpParser,
57
- GroupCommand,
58
- )
59
53
  from airflow.configuration import conf
60
54
  from airflow.exceptions import AirflowConfigException
61
55
  from airflow.models import Connection, DagModel, Pool, Variable
62
56
  from airflow.providers.common.compat.sdk import AirflowException
63
- from airflow.providers.fab.auth_manager.cli_commands.definition import (
64
- DB_COMMANDS,
65
- PERMISSIONS_CLEANUP_COMMAND,
66
- ROLES_COMMANDS,
67
- SYNC_PERM_COMMAND,
68
- USERS_COMMANDS,
69
- )
70
57
  from airflow.providers.fab.auth_manager.models import Permission, Role, User
71
58
  from airflow.providers.fab.auth_manager.models.anonymous_user import AnonymousUser
72
59
  from airflow.providers.fab.version_compat import AIRFLOW_V_3_1_PLUS
@@ -108,7 +95,7 @@ from airflow.providers.fab.www.utils import (
108
95
  get_fab_action_from_method_map,
109
96
  get_method_from_fab_action_map,
110
97
  )
111
- from airflow.utils.session import NEW_SESSION, create_session, provide_session
98
+ from airflow.utils.session import NEW_SESSION, provide_session
112
99
  from airflow.utils.yaml import safe_load
113
100
 
114
101
  if TYPE_CHECKING:
@@ -175,6 +162,7 @@ _MAP_MENU_ITEM_TO_FAB_RESOURCE_TYPE = {
175
162
  MenuItem.XCOMS: RESOURCE_XCOM,
176
163
  }
177
164
 
165
+ CACHE_TTL = conf.getint("fab", "cache_ttl", fallback=30)
178
166
 
179
167
  if AIRFLOW_V_3_1_PLUS:
180
168
  from airflow.providers.fab.www.security.permissions import RESOURCE_HITL_DETAIL
@@ -190,6 +178,7 @@ class FabAuthManager(BaseAuthManager[User]):
190
178
  This auth manager is responsible for providing a backward compatible user management experience to users.
191
179
  """
192
180
 
181
+ cache: TTLCache = TTLCache(maxsize=1024, ttl=CACHE_TTL)
193
182
  appbuilder: AirflowAppBuilder | None = None
194
183
 
195
184
  def init_flask_resources(self) -> None:
@@ -202,26 +191,9 @@ class FabAuthManager(BaseAuthManager[User]):
202
191
  @staticmethod
203
192
  def get_cli_commands() -> list[CLICommand]:
204
193
  """Vends CLI commands to be included in Airflow CLI."""
205
- commands: list[CLICommand] = [
206
- GroupCommand(
207
- name="users",
208
- help="Manage users",
209
- subcommands=USERS_COMMANDS,
210
- ),
211
- GroupCommand(
212
- name="roles",
213
- help="Manage roles",
214
- subcommands=ROLES_COMMANDS,
215
- ),
216
- SYNC_PERM_COMMAND, # not in a command group
217
- PERMISSIONS_CLEANUP_COMMAND, # single command for permissions cleanup
218
- ]
219
- # If Airflow version is 3.0.0 or higher, add the fab-db command group
220
- if packaging.version.parse(
221
- packaging.version.parse(airflow_version).base_version
222
- ) >= packaging.version.parse("3.0.0"):
223
- commands.append(GroupCommand(name="fab-db", help="Manage FAB", subcommands=DB_COMMANDS))
224
- return commands
194
+ from airflow.providers.fab.cli.definition import get_fab_cli_commands
195
+
196
+ return get_fab_cli_commands()
225
197
 
226
198
  def get_fastapi_app(self) -> FastAPI | None:
227
199
  """Get the FastAPI app."""
@@ -229,6 +201,7 @@ class FabAuthManager(BaseAuthManager[User]):
229
201
  login_router,
230
202
  )
231
203
  from airflow.providers.fab.auth_manager.api_fastapi.routes.roles import roles_router
204
+ from airflow.providers.fab.auth_manager.api_fastapi.routes.users import users_router
232
205
 
233
206
  flask_app = create_app(enable_plugins=False)
234
207
 
@@ -245,6 +218,7 @@ class FabAuthManager(BaseAuthManager[User]):
245
218
  # Add the login router to the FastAPI app
246
219
  app.include_router(login_router)
247
220
  app.include_router(roles_router)
221
+ app.include_router(users_router)
248
222
 
249
223
  app.mount("/", WSGIMiddleware(flask_app))
250
224
 
@@ -284,9 +258,13 @@ class FabAuthManager(BaseAuthManager[User]):
284
258
 
285
259
  return current_user
286
260
 
261
+ @property
262
+ def session(self):
263
+ return self.appbuilder.session
264
+
265
+ @cachedmethod(lambda self: self.cache, key=lambda _, token: int(token["sub"]))
287
266
  def deserialize_user(self, token: dict[str, Any]) -> User:
288
- with create_session() as session:
289
- return session.scalars(select(User).where(User.id == int(token["sub"]))).one()
267
+ return self.session.scalars(select(User).where(User.id == int(token["sub"]))).one()
290
268
 
291
269
  def serialize_user(self, user: User) -> dict[str, Any]:
292
270
  return {"sub": str(user.id)}
@@ -294,13 +272,13 @@ class FabAuthManager(BaseAuthManager[User]):
294
272
  def is_logged_in(self) -> bool:
295
273
  """Return whether the user is logged in."""
296
274
  user = self.get_user()
297
- return (
275
+ return bool(
298
276
  self.appbuilder
299
277
  and self.appbuilder.app.config.get("AUTH_ROLE_PUBLIC", None)
300
278
  or (not user.is_anonymous and user.is_active)
301
279
  )
302
280
 
303
- def create_token(self, headers: dict[str, str], body: dict[str, Any]) -> User:
281
+ def create_token(self, headers: dict[str, str], body: dict[str, Any]) -> User | None:
304
282
  """
305
283
  Create a new token from a payload.
306
284
 
@@ -760,14 +738,3 @@ class FabAuthManager(BaseAuthManager[User]):
760
738
  # delete the old ones.
761
739
  if conf.getboolean("fab", "UPDATE_FAB_PERMS"):
762
740
  self.security_manager.sync_roles()
763
-
764
-
765
- def get_parser() -> argparse.ArgumentParser:
766
- """Generate documentation; used by Sphinx argparse."""
767
- from airflow.cli.cli_parser import AirflowHelpFormatter, _add_command
768
-
769
- parser = DefaultHelpParser(prog="airflow", formatter_class=AirflowHelpFormatter)
770
- subparsers = parser.add_subparsers(dest="subcommand", metavar="GROUP_OR_COMMAND")
771
- for group_command in FabAuthManager.get_cli_commands():
772
- _add_command(subparsers, group_command)
773
- return parser
@@ -235,14 +235,14 @@ class User(Model, BaseUser):
235
235
  Sequence("ab_user_id_seq", start=1, increment=1, minvalue=1, cycle=False),
236
236
  primary_key=True,
237
237
  )
238
- first_name: Mapped[str] = mapped_column(String(64), nullable=False)
239
- last_name: Mapped[str] = mapped_column(String(64), nullable=False)
238
+ first_name: Mapped[str] = mapped_column(String(256), nullable=False)
239
+ last_name: Mapped[str] = mapped_column(String(256), nullable=False)
240
240
  username: Mapped[str] = mapped_column(
241
241
  String(512).with_variant(String(512, collation="NOCASE"), "sqlite"), unique=True, nullable=False
242
242
  )
243
243
  password: Mapped[str | None] = mapped_column(String(256))
244
244
  active: Mapped[bool | None] = mapped_column(Boolean, default=True)
245
- email: Mapped[str] = mapped_column(String(320), unique=True, nullable=False)
245
+ email: Mapped[str] = mapped_column(String(512), unique=True, nullable=False)
246
246
  last_login: Mapped[datetime.datetime | None] = mapped_column(DateTime, nullable=True)
247
247
  login_count: Mapped[int | None] = mapped_column(Integer, nullable=True)
248
248
  fail_login_count: Mapped[int | None] = mapped_column(Integer, nullable=True)
@@ -349,13 +349,13 @@ class RegisterUser(Model):
349
349
  Sequence("ab_register_user_id_seq", start=1, increment=1, minvalue=1, cycle=False),
350
350
  primary_key=True,
351
351
  )
352
- first_name: Mapped[str] = mapped_column(String(64), nullable=False)
353
- last_name: Mapped[str] = mapped_column(String(64), nullable=False)
352
+ first_name: Mapped[str] = mapped_column(String(256), nullable=False)
353
+ last_name: Mapped[str] = mapped_column(String(256), nullable=False)
354
354
  username: Mapped[str] = mapped_column(
355
355
  String(512).with_variant(String(512, collation="NOCASE"), "sqlite"), unique=True, nullable=False
356
356
  )
357
357
  password: Mapped[str | None] = mapped_column(String(256))
358
- email: Mapped[str] = mapped_column(String(320), unique=True, nullable=False)
358
+ email: Mapped[str] = mapped_column(String(512), unique=True, nullable=False)
359
359
  registration_date: Mapped[datetime.datetime | None] = mapped_column(
360
360
  DateTime, default=lambda: datetime.datetime.now(), nullable=True
361
361
  )