apache-airflow-providers-fab 3.1.1rc1__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.
- airflow/providers/fab/__init__.py +1 -1
- airflow/providers/fab/auth_manager/api_endpoints/user_endpoint.py +3 -1
- airflow/providers/fab/auth_manager/api_fastapi/datamodels/roles.py +13 -7
- airflow/providers/fab/auth_manager/api_fastapi/datamodels/users.py +68 -0
- airflow/providers/fab/auth_manager/api_fastapi/openapi/v2-fab-auth-manager-generated.yaml +485 -18
- airflow/providers/fab/auth_manager/api_fastapi/routes/login.py +2 -4
- airflow/providers/fab/auth_manager/api_fastapi/routes/users.py +133 -0
- airflow/providers/fab/auth_manager/api_fastapi/services/login.py +1 -2
- airflow/providers/fab/auth_manager/api_fastapi/services/users.py +219 -0
- airflow/providers/fab/auth_manager/cli_commands/db_command.py +2 -2
- airflow/providers/fab/auth_manager/cli_commands/user_command.py +3 -3
- airflow/providers/fab/auth_manager/fab_auth_manager.py +18 -51
- airflow/providers/fab/auth_manager/models/__init__.py +6 -6
- airflow/providers/fab/auth_manager/security_manager/override.py +90 -77
- airflow/providers/fab/auth_manager/views/user.py +12 -0
- airflow/providers/fab/cli/__init__.py +18 -0
- airflow/providers/fab/{auth_manager/cli_commands → cli}/definition.py +50 -2
- airflow/providers/fab/get_provider_info.py +8 -0
- airflow/providers/fab/www/app.py +2 -7
- airflow/providers/fab/www/extensions/init_appbuilder.py +3 -2
- airflow/providers/fab/www/package-lock.json +669 -531
- airflow/providers/fab/www/package.json +9 -9
- airflow/providers/fab/www/static/dist/{743.0c0bf201ae17e66a9a3f.js → 743.8fb7d21632ed892227fe.js} +2 -2
- airflow/providers/fab/www/static/dist/{airflowDefaultTheme.ef6fc04c9b6920cd75c9.js → airflowDefaultTheme.51e5d14856ee1ebc83ca.js} +1 -1
- airflow/providers/fab/www/static/dist/{flash.eaaf777ec1b3628cf7be.js → flash.865b6940c00b2a9041b3.js} +1 -1
- airflow/providers/fab/www/static/dist/{loadingDots.76f4332c0a932c3dc08f.js → loadingDots.07f5b9805847242736e1.js} +1 -1
- airflow/providers/fab/www/static/dist/main.8cffe40bcf7cca998f4e.js +2 -0
- airflow/providers/fab/www/static/dist/manifest.json +13 -13
- airflow/providers/fab/www/static/dist/{materialIcons.ad07a489b2f0fc1a96bf.js → materialIcons.4fe84ae36604d84dec78.js} +1 -1
- airflow/providers/fab/www/static/dist/moment.0ec3ee3fb60dc999b1fd.js +1 -0
- airflow/providers/fab/www/static/js/main.js +11 -0
- airflow/providers/fab/www/templates/airflow/main.html +1 -0
- {apache_airflow_providers_fab-3.1.1rc1.dist-info → apache_airflow_providers_fab-3.2.0rc1.dist-info}/METADATA +10 -10
- {apache_airflow_providers_fab-3.1.1rc1.dist-info → apache_airflow_providers_fab-3.2.0rc1.dist-info}/RECORD +47 -43
- {apache_airflow_providers_fab-3.1.1rc1.dist-info → apache_airflow_providers_fab-3.2.0rc1.dist-info}/licenses/3rd-party-licenses/LICENSES-ui.txt +1 -1
- {apache_airflow_providers_fab-3.1.1rc1.dist-info → apache_airflow_providers_fab-3.2.0rc1.dist-info}/licenses/NOTICE +1 -1
- airflow/providers/fab/www/static/dist/main.bc1f701c3d133e2a3bab.js +0 -2
- airflow/providers/fab/www/static/dist/moment.5b85b4f6be2fe9c405ac.js +0 -1
- /airflow/providers/fab/www/static/dist/{743.0c0bf201ae17e66a9a3f.js.LICENSE.txt → 743.8fb7d21632ed892227fe.js.LICENSE.txt} +0 -0
- /airflow/providers/fab/www/static/dist/{airflowDefaultTheme.ef6fc04c9b6920cd75c9.css → airflowDefaultTheme.51e5d14856ee1ebc83ca.css} +0 -0
- /airflow/providers/fab/www/static/dist/{flash.eaaf777ec1b3628cf7be.css → flash.865b6940c00b2a9041b3.css} +0 -0
- /airflow/providers/fab/www/static/dist/{loadingDots.76f4332c0a932c3dc08f.css → loadingDots.07f5b9805847242736e1.css} +0 -0
- /airflow/providers/fab/www/static/dist/{main.bc1f701c3d133e2a3bab.css → main.8cffe40bcf7cca998f4e.css} +0 -0
- /airflow/providers/fab/www/static/dist/{main.bc1f701c3d133e2a3bab.js.LICENSE.txt → main.8cffe40bcf7cca998f4e.js.LICENSE.txt} +0 -0
- /airflow/providers/fab/www/static/dist/{materialIcons.ad07a489b2f0fc1a96bf.css → materialIcons.4fe84ae36604d84dec78.css} +0 -0
- /airflow/providers/fab/www/static/dist/{runtime.254c277d91ce3ac79c64.js → runtime.45b36fb8335446865b53.js} +0 -0
- {apache_airflow_providers_fab-3.1.1rc1.dist-info → apache_airflow_providers_fab-3.2.0rc1.dist-info}/WHEEL +0 -0
- {apache_airflow_providers_fab-3.1.1rc1.dist-info → apache_airflow_providers_fab-3.2.0rc1.dist-info}/entry_points.txt +0 -0
- {apache_airflow_providers_fab-3.1.1rc1.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
|
|
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
|
-
|
|
50
|
-
run_db_downgrade_command(args,
|
|
49
|
+
downgrade_command = FABDBManager(session).downgrade
|
|
50
|
+
run_db_downgrade_command(args, downgrade_command, revision_heads_map=_REVISION_HEADS_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
|
|
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
|
|
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
|
|
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
|
|
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,
|
|
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
|
-
|
|
206
|
-
|
|
207
|
-
|
|
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
|
-
|
|
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(
|
|
239
|
-
last_name: Mapped[str] = mapped_column(String(
|
|
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(
|
|
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(
|
|
353
|
-
last_name: Mapped[str] = mapped_column(String(
|
|
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(
|
|
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
|
)
|