apache-airflow-providers-fab 3.0.1__py3-none-any.whl → 3.1.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 +2 -2
- airflow/providers/fab/auth_manager/api_fastapi/datamodels/login.py +0 -7
- airflow/providers/fab/auth_manager/api_fastapi/datamodels/roles.py +63 -0
- airflow/providers/fab/auth_manager/api_fastapi/openapi/v2-fab-auth-manager-generated.yaml +416 -16
- airflow/providers/fab/auth_manager/api_fastapi/parameters.py +55 -0
- airflow/providers/fab/auth_manager/api_fastapi/routes/login.py +37 -5
- airflow/providers/fab/auth_manager/api_fastapi/routes/roles.py +137 -0
- airflow/providers/fab/auth_manager/api_fastapi/security.py +32 -0
- airflow/providers/fab/auth_manager/api_fastapi/services/login.py +12 -25
- airflow/providers/fab/auth_manager/api_fastapi/services/roles.py +158 -0
- airflow/providers/fab/auth_manager/api_fastapi/sorting.py +49 -0
- airflow/providers/fab/auth_manager/fab_auth_manager.py +33 -3
- airflow/providers/fab/auth_manager/models/__init__.py +3 -8
- airflow/providers/fab/auth_manager/models/db.py +1 -1
- airflow/providers/fab/auth_manager/security_manager/override.py +52 -9
- airflow/providers/fab/www/api_connexion/parameters.py +1 -46
- airflow/providers/fab/www/app.py +13 -10
- airflow/providers/fab/www/extensions/init_appbuilder.py +5 -2
- airflow/providers/fab/www/extensions/init_security.py +1 -1
- airflow/providers/fab/www/package-lock.json +315 -217
- airflow/providers/fab/www/package.json +9 -9
- airflow/providers/fab/www/session.py +5 -8
- airflow/providers/fab/www/static/dist/{743.935ed3d26e56ed8f63d3.js → 743.0c0bf201ae17e66a9a3f.js} +1 -1
- airflow/providers/fab/www/static/dist/{main.3cf3be1a0c5439bb640d.js → main.bc1f701c3d133e2a3bab.js} +1 -1
- airflow/providers/fab/www/static/dist/manifest.json +13 -13
- airflow/providers/fab/www/views.py +18 -14
- {apache_airflow_providers_fab-3.0.1.dist-info → apache_airflow_providers_fab-3.1.0rc1.dist-info}/METADATA +15 -14
- {apache_airflow_providers_fab-3.0.1.dist-info → apache_airflow_providers_fab-3.1.0rc1.dist-info}/RECORD +48 -42
- /airflow/providers/fab/migrations/versions/{0001_1_4_0_create_ab_tables_if_missing.py → 0000_1_4_0_create_ab_tables_if_missing.py} +0 -0
- /airflow/providers/fab/www/static/dist/{743.935ed3d26e56ed8f63d3.js.LICENSE.txt → 743.0c0bf201ae17e66a9a3f.js.LICENSE.txt} +0 -0
- /airflow/providers/fab/www/static/dist/{airflowDefaultTheme.ff5a35f322070b094aa2.css → airflowDefaultTheme.ef6fc04c9b6920cd75c9.css} +0 -0
- /airflow/providers/fab/www/static/dist/{airflowDefaultTheme.ff5a35f322070b094aa2.js → airflowDefaultTheme.ef6fc04c9b6920cd75c9.js} +0 -0
- /airflow/providers/fab/www/static/dist/{flash.5583a9e0cf11f2be93da.css → flash.eaaf777ec1b3628cf7be.css} +0 -0
- /airflow/providers/fab/www/static/dist/{flash.5583a9e0cf11f2be93da.js → flash.eaaf777ec1b3628cf7be.js} +0 -0
- /airflow/providers/fab/www/static/dist/{loadingDots.2e5f555f0753107b0300.css → loadingDots.76f4332c0a932c3dc08f.css} +0 -0
- /airflow/providers/fab/www/static/dist/{loadingDots.2e5f555f0753107b0300.js → loadingDots.76f4332c0a932c3dc08f.js} +0 -0
- /airflow/providers/fab/www/static/dist/{main.3cf3be1a0c5439bb640d.css → main.bc1f701c3d133e2a3bab.css} +0 -0
- /airflow/providers/fab/www/static/dist/{main.3cf3be1a0c5439bb640d.js.LICENSE.txt → main.bc1f701c3d133e2a3bab.js.LICENSE.txt} +0 -0
- /airflow/providers/fab/www/static/dist/{materialIcons.3e67dd6fbfcc4f3b5105.css → materialIcons.ad07a489b2f0fc1a96bf.css} +0 -0
- /airflow/providers/fab/www/static/dist/{materialIcons.3e67dd6fbfcc4f3b5105.js → materialIcons.ad07a489b2f0fc1a96bf.js} +0 -0
- /airflow/providers/fab/www/static/dist/{moment.9baee5ec3d7639a10897.js → moment.5b85b4f6be2fe9c405ac.js} +0 -0
- /airflow/providers/fab/www/static/dist/{runtime.6ad9da077ea169d60db9.js → runtime.254c277d91ce3ac79c64.js} +0 -0
- {apache_airflow_providers_fab-3.0.1.dist-info → apache_airflow_providers_fab-3.1.0rc1.dist-info}/WHEEL +0 -0
- {apache_airflow_providers_fab-3.0.1.dist-info → apache_airflow_providers_fab-3.1.0rc1.dist-info}/entry_points.txt +0 -0
- {apache_airflow_providers_fab-3.0.1.dist-info → apache_airflow_providers_fab-3.1.0rc1.dist-info}/licenses/3rd-party-licenses/LICENSES-ui.txt +0 -0
- {airflow/providers/fab → apache_airflow_providers_fab-3.1.0rc1.dist-info/licenses}/LICENSE +0 -0
- {apache_airflow_providers_fab-3.0.1.dist-info → apache_airflow_providers_fab-3.1.0rc1.dist-info}/licenses/NOTICE +0 -0
|
@@ -0,0 +1,137 @@
|
|
|
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 typing import TYPE_CHECKING
|
|
20
|
+
|
|
21
|
+
from fastapi import Depends, Path, Query, status
|
|
22
|
+
|
|
23
|
+
from airflow.api_fastapi.common.router import AirflowRouter
|
|
24
|
+
from airflow.api_fastapi.core_api.openapi.exceptions import create_openapi_http_exception_doc
|
|
25
|
+
from airflow.providers.fab.auth_manager.api_fastapi.datamodels.roles import (
|
|
26
|
+
RoleBody,
|
|
27
|
+
RoleCollectionResponse,
|
|
28
|
+
RoleResponse,
|
|
29
|
+
)
|
|
30
|
+
from airflow.providers.fab.auth_manager.api_fastapi.parameters import get_effective_limit
|
|
31
|
+
from airflow.providers.fab.auth_manager.api_fastapi.security import requires_fab_custom_view
|
|
32
|
+
from airflow.providers.fab.auth_manager.api_fastapi.services.roles import FABAuthManagerRoles
|
|
33
|
+
from airflow.providers.fab.auth_manager.cli_commands.utils import get_application_builder
|
|
34
|
+
from airflow.providers.fab.www.security import permissions
|
|
35
|
+
|
|
36
|
+
if TYPE_CHECKING:
|
|
37
|
+
from airflow.providers.fab.auth_manager.api_fastapi.datamodels.roles import (
|
|
38
|
+
RoleBody,
|
|
39
|
+
RoleResponse,
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
roles_router = AirflowRouter(prefix="/fab/v1", tags=["FabAuthManager"])
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
@roles_router.post(
|
|
47
|
+
"/roles",
|
|
48
|
+
responses=create_openapi_http_exception_doc(
|
|
49
|
+
[
|
|
50
|
+
status.HTTP_400_BAD_REQUEST,
|
|
51
|
+
status.HTTP_401_UNAUTHORIZED,
|
|
52
|
+
status.HTTP_403_FORBIDDEN,
|
|
53
|
+
status.HTTP_409_CONFLICT,
|
|
54
|
+
status.HTTP_500_INTERNAL_SERVER_ERROR,
|
|
55
|
+
]
|
|
56
|
+
),
|
|
57
|
+
dependencies=[Depends(requires_fab_custom_view("POST", permissions.RESOURCE_ROLE))],
|
|
58
|
+
)
|
|
59
|
+
def create_role(body: RoleBody) -> RoleResponse:
|
|
60
|
+
"""Create a new role (actions can be empty)."""
|
|
61
|
+
with get_application_builder():
|
|
62
|
+
return FABAuthManagerRoles.create_role(body=body)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
@roles_router.get(
|
|
66
|
+
"/roles",
|
|
67
|
+
response_model=RoleCollectionResponse,
|
|
68
|
+
responses=create_openapi_http_exception_doc(
|
|
69
|
+
[
|
|
70
|
+
status.HTTP_400_BAD_REQUEST,
|
|
71
|
+
status.HTTP_401_UNAUTHORIZED,
|
|
72
|
+
status.HTTP_403_FORBIDDEN,
|
|
73
|
+
status.HTTP_500_INTERNAL_SERVER_ERROR,
|
|
74
|
+
]
|
|
75
|
+
),
|
|
76
|
+
dependencies=[Depends(requires_fab_custom_view("GET", permissions.RESOURCE_ROLE))],
|
|
77
|
+
)
|
|
78
|
+
def get_roles(
|
|
79
|
+
order_by: str = Query("name", description="Field to order by. Prefix with '-' for descending."),
|
|
80
|
+
limit: int = Depends(get_effective_limit()),
|
|
81
|
+
offset: int = Query(0, ge=0, description="Number of items to skip before starting to collect results."),
|
|
82
|
+
) -> RoleCollectionResponse:
|
|
83
|
+
"""List roles with pagination and ordering."""
|
|
84
|
+
with get_application_builder():
|
|
85
|
+
return FABAuthManagerRoles.get_roles(order_by=order_by, limit=limit, offset=offset)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
@roles_router.delete(
|
|
89
|
+
"/roles/{name}",
|
|
90
|
+
responses=create_openapi_http_exception_doc(
|
|
91
|
+
[
|
|
92
|
+
status.HTTP_401_UNAUTHORIZED,
|
|
93
|
+
status.HTTP_403_FORBIDDEN,
|
|
94
|
+
status.HTTP_404_NOT_FOUND,
|
|
95
|
+
]
|
|
96
|
+
),
|
|
97
|
+
dependencies=[Depends(requires_fab_custom_view("DELETE", permissions.RESOURCE_ROLE))],
|
|
98
|
+
)
|
|
99
|
+
def delete_role(name: str = Path(..., min_length=1)) -> None:
|
|
100
|
+
"""Delete an existing role."""
|
|
101
|
+
with get_application_builder():
|
|
102
|
+
return FABAuthManagerRoles.delete_role(name=name)
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
@roles_router.get(
|
|
106
|
+
"/roles/{name}",
|
|
107
|
+
responses=create_openapi_http_exception_doc(
|
|
108
|
+
[status.HTTP_401_UNAUTHORIZED, status.HTTP_403_FORBIDDEN, status.HTTP_404_NOT_FOUND]
|
|
109
|
+
),
|
|
110
|
+
dependencies=[Depends(requires_fab_custom_view("GET", permissions.RESOURCE_ROLE))],
|
|
111
|
+
)
|
|
112
|
+
def get_role(name: str = Path(..., min_length=1)) -> RoleResponse:
|
|
113
|
+
"""Get an existing role."""
|
|
114
|
+
with get_application_builder():
|
|
115
|
+
return FABAuthManagerRoles.get_role(name=name)
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
@roles_router.patch(
|
|
119
|
+
"/roles/{name}",
|
|
120
|
+
responses=create_openapi_http_exception_doc(
|
|
121
|
+
[
|
|
122
|
+
status.HTTP_400_BAD_REQUEST,
|
|
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("PATCH", permissions.RESOURCE_ROLE))],
|
|
129
|
+
)
|
|
130
|
+
def patch_role(
|
|
131
|
+
body: RoleBody,
|
|
132
|
+
name: str = Path(..., min_length=1),
|
|
133
|
+
update_mask: str | None = Query(None, description="Comma-separated list of fields to update"),
|
|
134
|
+
) -> RoleResponse:
|
|
135
|
+
"""Update an existing role."""
|
|
136
|
+
with get_application_builder():
|
|
137
|
+
return FABAuthManagerRoles.patch_role(name=name, body=body, update_mask=update_mask)
|
|
@@ -0,0 +1,32 @@
|
|
|
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, HTTPException, status
|
|
20
|
+
|
|
21
|
+
from airflow.api_fastapi.app import get_auth_manager
|
|
22
|
+
from airflow.api_fastapi.core_api.security import get_user
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def requires_fab_custom_view(method: str, resource_name: str):
|
|
26
|
+
def _check(user=Depends(get_user)):
|
|
27
|
+
if not get_auth_manager().is_authorized_custom_view(
|
|
28
|
+
method=method, resource_name=resource_name, user=user
|
|
29
|
+
):
|
|
30
|
+
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Forbidden")
|
|
31
|
+
|
|
32
|
+
return _check
|
|
@@ -16,19 +16,14 @@
|
|
|
16
16
|
# under the License.
|
|
17
17
|
from __future__ import annotations
|
|
18
18
|
|
|
19
|
-
from typing import
|
|
19
|
+
from typing import Any
|
|
20
20
|
|
|
21
|
-
from flask_appbuilder.const import AUTH_LDAP
|
|
22
21
|
from starlette import status
|
|
23
22
|
from starlette.exceptions import HTTPException
|
|
24
23
|
|
|
25
|
-
from airflow.api_fastapi.app import get_auth_manager
|
|
26
24
|
from airflow.configuration import conf
|
|
27
|
-
from airflow.providers.fab.auth_manager.api_fastapi.datamodels.login import
|
|
28
|
-
|
|
29
|
-
if TYPE_CHECKING:
|
|
30
|
-
from airflow.providers.fab.auth_manager.fab_auth_manager import FabAuthManager
|
|
31
|
-
from airflow.providers.fab.auth_manager.models import User
|
|
25
|
+
from airflow.providers.fab.auth_manager.api_fastapi.datamodels.login import LoginResponse
|
|
26
|
+
from airflow.providers.fab.www.utils import get_fab_auth_manager
|
|
32
27
|
|
|
33
28
|
|
|
34
29
|
class FABAuthManagerLogin:
|
|
@@ -36,25 +31,17 @@ class FABAuthManagerLogin:
|
|
|
36
31
|
|
|
37
32
|
@classmethod
|
|
38
33
|
def create_token(
|
|
39
|
-
cls,
|
|
34
|
+
cls,
|
|
35
|
+
headers: dict[str, str],
|
|
36
|
+
body: dict[str, Any],
|
|
37
|
+
expiration_time_in_seconds: int = conf.getint("api_auth", "jwt_expiration_time"),
|
|
40
38
|
) -> LoginResponse:
|
|
41
39
|
"""Create a new token."""
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
auth_manager = cast("FabAuthManager", get_auth_manager())
|
|
48
|
-
user: User | None = None
|
|
49
|
-
|
|
50
|
-
if auth_manager.security_manager.auth_type == AUTH_LDAP:
|
|
51
|
-
user = auth_manager.security_manager.auth_user_ldap(
|
|
52
|
-
body.username, body.password, rotate_session_id=False
|
|
53
|
-
)
|
|
54
|
-
if user is None:
|
|
55
|
-
user = auth_manager.security_manager.auth_user_db(
|
|
56
|
-
body.username, body.password, rotate_session_id=False
|
|
57
|
-
)
|
|
40
|
+
auth_manager = get_fab_auth_manager()
|
|
41
|
+
try:
|
|
42
|
+
user = auth_manager.create_token(headers=headers, body=body)
|
|
43
|
+
except ValueError as e:
|
|
44
|
+
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e))
|
|
58
45
|
|
|
59
46
|
if not user:
|
|
60
47
|
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid credentials")
|
|
@@ -0,0 +1,158 @@
|
|
|
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 typing import TYPE_CHECKING
|
|
20
|
+
|
|
21
|
+
from fastapi import HTTPException, status
|
|
22
|
+
from sqlalchemy import func, select
|
|
23
|
+
|
|
24
|
+
from airflow.providers.fab.auth_manager.api_fastapi.datamodels.roles import (
|
|
25
|
+
RoleBody,
|
|
26
|
+
RoleCollectionResponse,
|
|
27
|
+
RoleResponse,
|
|
28
|
+
)
|
|
29
|
+
from airflow.providers.fab.auth_manager.api_fastapi.sorting import build_ordering
|
|
30
|
+
from airflow.providers.fab.auth_manager.models import Role
|
|
31
|
+
from airflow.providers.fab.www.utils import get_fab_auth_manager
|
|
32
|
+
|
|
33
|
+
if TYPE_CHECKING:
|
|
34
|
+
from airflow.providers.fab.auth_manager.security_manager.override import FabAirflowSecurityManagerOverride
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class FABAuthManagerRoles:
|
|
38
|
+
"""Service layer for FAB Auth Manager role operations (create, validate, sync)."""
|
|
39
|
+
|
|
40
|
+
@staticmethod
|
|
41
|
+
def _check_action_and_resource(
|
|
42
|
+
security_manager: FabAirflowSecurityManagerOverride,
|
|
43
|
+
perms: list[tuple[str, str]],
|
|
44
|
+
) -> None:
|
|
45
|
+
for action_name, resource_name in perms:
|
|
46
|
+
if not security_manager.get_action(action_name):
|
|
47
|
+
raise HTTPException(
|
|
48
|
+
status_code=status.HTTP_400_BAD_REQUEST,
|
|
49
|
+
detail=f"The specified action: {action_name!r} was not found",
|
|
50
|
+
)
|
|
51
|
+
if not security_manager.get_resource(resource_name):
|
|
52
|
+
raise HTTPException(
|
|
53
|
+
status_code=status.HTTP_400_BAD_REQUEST,
|
|
54
|
+
detail=f"The specified resource: {resource_name!r} was not found",
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
@classmethod
|
|
58
|
+
def create_role(cls, body: RoleBody) -> RoleResponse:
|
|
59
|
+
security_manager = get_fab_auth_manager().security_manager
|
|
60
|
+
|
|
61
|
+
existing = security_manager.find_role(name=body.name)
|
|
62
|
+
if existing:
|
|
63
|
+
raise HTTPException(
|
|
64
|
+
status_code=status.HTTP_409_CONFLICT,
|
|
65
|
+
detail=f"Role with name {body.name!r} already exists; please update with the PATCH endpoint",
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
perms: list[tuple[str, str]] = [(ar.action.name, ar.resource.name) for ar in (body.permissions or [])]
|
|
69
|
+
|
|
70
|
+
cls._check_action_and_resource(security_manager, perms)
|
|
71
|
+
|
|
72
|
+
security_manager.bulk_sync_roles([{"role": body.name, "perms": perms}])
|
|
73
|
+
|
|
74
|
+
created = security_manager.find_role(name=body.name)
|
|
75
|
+
if not created:
|
|
76
|
+
raise HTTPException(
|
|
77
|
+
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
|
78
|
+
detail="Role was not created due to an unexpected error.",
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
return RoleResponse.model_validate(created)
|
|
82
|
+
|
|
83
|
+
@classmethod
|
|
84
|
+
def get_roles(cls, *, order_by: str, limit: int, offset: int) -> RoleCollectionResponse:
|
|
85
|
+
security_manager = get_fab_auth_manager().security_manager
|
|
86
|
+
session = security_manager.session
|
|
87
|
+
|
|
88
|
+
total_entries = session.scalars(select(func.count(Role.id))).one()
|
|
89
|
+
|
|
90
|
+
ordering = build_ordering(order_by, allowed={"name": Role.name, "role_id": Role.id})
|
|
91
|
+
|
|
92
|
+
stmt = select(Role).order_by(ordering).offset(offset).limit(limit)
|
|
93
|
+
roles = session.scalars(stmt).unique().all()
|
|
94
|
+
|
|
95
|
+
return RoleCollectionResponse(
|
|
96
|
+
roles=[RoleResponse.model_validate(r) for r in roles],
|
|
97
|
+
total_entries=total_entries,
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
@classmethod
|
|
101
|
+
def delete_role(cls, name: str) -> None:
|
|
102
|
+
security_manager = get_fab_auth_manager().security_manager
|
|
103
|
+
|
|
104
|
+
existing = security_manager.find_role(name=name)
|
|
105
|
+
if not existing:
|
|
106
|
+
raise HTTPException(
|
|
107
|
+
status_code=status.HTTP_404_NOT_FOUND,
|
|
108
|
+
detail=f"Role with name {name!r} does not exist.",
|
|
109
|
+
)
|
|
110
|
+
security_manager.delete_role(existing)
|
|
111
|
+
|
|
112
|
+
@classmethod
|
|
113
|
+
def get_role(cls, name: str) -> RoleResponse:
|
|
114
|
+
security_manager = get_fab_auth_manager().security_manager
|
|
115
|
+
|
|
116
|
+
existing = security_manager.find_role(name=name)
|
|
117
|
+
if not existing:
|
|
118
|
+
raise HTTPException(
|
|
119
|
+
status_code=status.HTTP_404_NOT_FOUND,
|
|
120
|
+
detail=f"Role with name {name!r} does not exist.",
|
|
121
|
+
)
|
|
122
|
+
return RoleResponse.model_validate(existing)
|
|
123
|
+
|
|
124
|
+
@classmethod
|
|
125
|
+
def patch_role(cls, body: RoleBody, name: str, update_mask: str | None = None) -> RoleResponse:
|
|
126
|
+
security_manager = get_fab_auth_manager().security_manager
|
|
127
|
+
|
|
128
|
+
existing = security_manager.find_role(name=name)
|
|
129
|
+
if not existing:
|
|
130
|
+
raise HTTPException(
|
|
131
|
+
status_code=status.HTTP_404_NOT_FOUND,
|
|
132
|
+
detail=f"Role with name {name!r} does not exist.",
|
|
133
|
+
)
|
|
134
|
+
|
|
135
|
+
if update_mask:
|
|
136
|
+
update_data = RoleResponse.model_validate(existing)
|
|
137
|
+
|
|
138
|
+
for field in update_mask:
|
|
139
|
+
if field == "actions":
|
|
140
|
+
update_data.permissions = body.permissions
|
|
141
|
+
elif hasattr(body, field):
|
|
142
|
+
setattr(update_data, field, getattr(body, field))
|
|
143
|
+
else:
|
|
144
|
+
raise HTTPException(
|
|
145
|
+
status_code=status.HTTP_400_BAD_REQUEST,
|
|
146
|
+
detail=f"'{field}' in update_mask is unknown",
|
|
147
|
+
)
|
|
148
|
+
else:
|
|
149
|
+
update_data = RoleResponse(name=body.name, permissions=body.permissions or [])
|
|
150
|
+
|
|
151
|
+
perms: list[tuple[str, str]] = [(ar.action.name, ar.resource.name) for ar in (body.permissions or [])]
|
|
152
|
+
cls._check_action_and_resource(security_manager, perms)
|
|
153
|
+
security_manager.bulk_sync_roles([{"role": name, "perms": perms}])
|
|
154
|
+
|
|
155
|
+
new_name = update_data.name
|
|
156
|
+
if new_name and new_name != existing.name:
|
|
157
|
+
security_manager.update_role(role_id=existing.id, name=new_name)
|
|
158
|
+
return RoleResponse.model_validate(update_data)
|
|
@@ -0,0 +1,49 @@
|
|
|
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 collections.abc import Mapping
|
|
20
|
+
from typing import TYPE_CHECKING, Any
|
|
21
|
+
|
|
22
|
+
from fastapi import HTTPException, status
|
|
23
|
+
from sqlalchemy import asc, desc
|
|
24
|
+
|
|
25
|
+
if TYPE_CHECKING:
|
|
26
|
+
from sqlalchemy.orm import InstrumentedAttribute
|
|
27
|
+
from sqlalchemy.sql.elements import ColumnElement
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def build_ordering(
|
|
31
|
+
order_by: str, *, allowed: Mapping[str, ColumnElement[Any]] | Mapping[str, InstrumentedAttribute[Any]]
|
|
32
|
+
) -> ColumnElement[Any]:
|
|
33
|
+
"""
|
|
34
|
+
Build an SQLAlchemy ORDER BY expression from the `order_by` parameter.
|
|
35
|
+
|
|
36
|
+
:param order_by: Public field name, optionally prefixed with "-" for descending.
|
|
37
|
+
:param allowed: Map of public field to SQLAlchemy column/expression.
|
|
38
|
+
"""
|
|
39
|
+
is_desc = order_by.startswith("-")
|
|
40
|
+
key = order_by.lstrip("-")
|
|
41
|
+
|
|
42
|
+
if key not in allowed:
|
|
43
|
+
raise HTTPException(
|
|
44
|
+
status_code=status.HTTP_400_BAD_REQUEST,
|
|
45
|
+
detail=f"Ordering with '{order_by}' is disallowed or the attribute does not exist on the model",
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
col = allowed[key]
|
|
49
|
+
return desc(col) if is_desc else asc(col)
|
|
@@ -27,6 +27,7 @@ import packaging.version
|
|
|
27
27
|
from connexion import FlaskApi
|
|
28
28
|
from fastapi import FastAPI
|
|
29
29
|
from flask import Blueprint, current_app, g
|
|
30
|
+
from flask_appbuilder.const import AUTH_LDAP
|
|
30
31
|
from sqlalchemy import select
|
|
31
32
|
from sqlalchemy.orm import Session, joinedload
|
|
32
33
|
from starlette.middleware.wsgi import WSGIMiddleware
|
|
@@ -56,8 +57,9 @@ from airflow.cli.cli_config import (
|
|
|
56
57
|
GroupCommand,
|
|
57
58
|
)
|
|
58
59
|
from airflow.configuration import conf
|
|
59
|
-
from airflow.exceptions import AirflowConfigException
|
|
60
|
+
from airflow.exceptions import AirflowConfigException
|
|
60
61
|
from airflow.models import Connection, DagModel, Pool, Variable
|
|
62
|
+
from airflow.providers.common.compat.sdk import AirflowException
|
|
61
63
|
from airflow.providers.fab.auth_manager.cli_commands.definition import (
|
|
62
64
|
DB_COMMANDS,
|
|
63
65
|
PERMISSIONS_CLEANUP_COMMAND,
|
|
@@ -78,6 +80,7 @@ from airflow.providers.fab.www.security import permissions
|
|
|
78
80
|
from airflow.providers.fab.www.security.permissions import (
|
|
79
81
|
ACTION_CAN_READ,
|
|
80
82
|
RESOURCE_AUDIT_LOG,
|
|
83
|
+
RESOURCE_BACKFILL,
|
|
81
84
|
RESOURCE_CLUSTER_ACTIVITY,
|
|
82
85
|
RESOURCE_CONFIG,
|
|
83
86
|
RESOURCE_CONNECTION,
|
|
@@ -105,7 +108,6 @@ from airflow.providers.fab.www.utils import (
|
|
|
105
108
|
get_fab_action_from_method_map,
|
|
106
109
|
get_method_from_fab_action_map,
|
|
107
110
|
)
|
|
108
|
-
from airflow.security.permissions import RESOURCE_BACKFILL
|
|
109
111
|
from airflow.utils.session import NEW_SESSION, create_session, provide_session
|
|
110
112
|
from airflow.utils.yaml import safe_load
|
|
111
113
|
|
|
@@ -226,6 +228,7 @@ class FabAuthManager(BaseAuthManager[User]):
|
|
|
226
228
|
from airflow.providers.fab.auth_manager.api_fastapi.routes.login import (
|
|
227
229
|
login_router,
|
|
228
230
|
)
|
|
231
|
+
from airflow.providers.fab.auth_manager.api_fastapi.routes.roles import roles_router
|
|
229
232
|
|
|
230
233
|
flask_app = create_app(enable_plugins=False)
|
|
231
234
|
|
|
@@ -241,6 +244,7 @@ class FabAuthManager(BaseAuthManager[User]):
|
|
|
241
244
|
|
|
242
245
|
# Add the login router to the FastAPI app
|
|
243
246
|
app.include_router(login_router)
|
|
247
|
+
app.include_router(roles_router)
|
|
244
248
|
|
|
245
249
|
app.mount("/", WSGIMiddleware(flask_app))
|
|
246
250
|
|
|
@@ -296,6 +300,32 @@ class FabAuthManager(BaseAuthManager[User]):
|
|
|
296
300
|
or (not user.is_anonymous and user.is_active)
|
|
297
301
|
)
|
|
298
302
|
|
|
303
|
+
def create_token(self, headers: dict[str, str], body: dict[str, Any]) -> User:
|
|
304
|
+
"""
|
|
305
|
+
Create a new token from a payload.
|
|
306
|
+
|
|
307
|
+
By default, it uses basic authentication (username and password).
|
|
308
|
+
Override this method to use a different authentication method (e.g. oauth).
|
|
309
|
+
|
|
310
|
+
:param headers: request headers
|
|
311
|
+
:param body: request body
|
|
312
|
+
"""
|
|
313
|
+
if not body.get("username") or not body.get("password"):
|
|
314
|
+
raise ValueError("Username and password must be provided")
|
|
315
|
+
|
|
316
|
+
user: User | None = None
|
|
317
|
+
|
|
318
|
+
if self.security_manager.auth_type == AUTH_LDAP:
|
|
319
|
+
user = self.security_manager.auth_user_ldap(
|
|
320
|
+
body["username"], body["password"], rotate_session_id=False
|
|
321
|
+
)
|
|
322
|
+
if user is None:
|
|
323
|
+
user = self.security_manager.auth_user_db(
|
|
324
|
+
body["username"], body["password"], rotate_session_id=False
|
|
325
|
+
)
|
|
326
|
+
|
|
327
|
+
return user
|
|
328
|
+
|
|
299
329
|
def is_authorized_configuration(
|
|
300
330
|
self,
|
|
301
331
|
*,
|
|
@@ -567,7 +597,7 @@ class FabAuthManager(BaseAuthManager[User]):
|
|
|
567
597
|
|
|
568
598
|
def get_url_logout(self) -> str | None:
|
|
569
599
|
"""Return the logout page url."""
|
|
570
|
-
return urljoin(self.apiserver_endpoint, f"{AUTH_MANAGER_FASTAPI_APP_PREFIX}/logout
|
|
600
|
+
return urljoin(self.apiserver_endpoint, f"{AUTH_MANAGER_FASTAPI_APP_PREFIX}/logout")
|
|
571
601
|
|
|
572
602
|
def register_views(self) -> None:
|
|
573
603
|
self.security_manager.register_views()
|
|
@@ -21,8 +21,6 @@ import datetime
|
|
|
21
21
|
# This product contains a modified portion of 'Flask App Builder' developed by Daniel Vaz Gaspar.
|
|
22
22
|
# (https://github.com/dpgaspar/Flask-AppBuilder).
|
|
23
23
|
# Copyright 2013, Daniel Vaz Gaspar
|
|
24
|
-
from typing import TYPE_CHECKING
|
|
25
|
-
|
|
26
24
|
from flask import current_app, g
|
|
27
25
|
from flask_appbuilder import Model
|
|
28
26
|
from sqlalchemy import (
|
|
@@ -45,12 +43,6 @@ from sqlalchemy.orm import Mapped, backref, declared_attr, relationship
|
|
|
45
43
|
from airflow.api_fastapi.auth.managers.models.base_user import BaseUser
|
|
46
44
|
from airflow.providers.common.compat.sqlalchemy.orm import mapped_column
|
|
47
45
|
|
|
48
|
-
if TYPE_CHECKING:
|
|
49
|
-
try:
|
|
50
|
-
from sqlalchemy import Identity
|
|
51
|
-
except Exception:
|
|
52
|
-
Identity = None
|
|
53
|
-
|
|
54
46
|
"""
|
|
55
47
|
Compatibility note: The models in this file are duplicated from Flask AppBuilder.
|
|
56
48
|
"""
|
|
@@ -157,6 +149,9 @@ class Resource(Model):
|
|
|
157
149
|
def __eq__(self, other):
|
|
158
150
|
return (isinstance(other, self.__class__)) and (self.name == other.name)
|
|
159
151
|
|
|
152
|
+
def __hash__(self):
|
|
153
|
+
return hash((self.id, self.name))
|
|
154
|
+
|
|
160
155
|
def __neq__(self, other):
|
|
161
156
|
return self.name != other.name
|
|
162
157
|
|
|
@@ -21,7 +21,7 @@ from pathlib import Path
|
|
|
21
21
|
from flask_appbuilder import Model
|
|
22
22
|
|
|
23
23
|
from airflow import settings
|
|
24
|
-
from airflow.
|
|
24
|
+
from airflow.providers.common.compat.sdk import AirflowException
|
|
25
25
|
from airflow.utils.db import _offline_migration, print_happy_cat
|
|
26
26
|
from airflow.utils.db_manager import BaseDBManager
|
|
27
27
|
|
|
@@ -67,7 +67,7 @@ from sqlalchemy.orm import joinedload
|
|
|
67
67
|
from werkzeug.security import check_password_hash, generate_password_hash
|
|
68
68
|
|
|
69
69
|
from airflow.configuration import conf
|
|
70
|
-
from airflow.
|
|
70
|
+
from airflow.providers.common.compat.sdk import AirflowException
|
|
71
71
|
from airflow.providers.fab.auth_manager.models import (
|
|
72
72
|
Action,
|
|
73
73
|
Group,
|
|
@@ -124,11 +124,17 @@ if AIRFLOW_V_3_1_PLUS:
|
|
|
124
124
|
with create_session() as session:
|
|
125
125
|
yield from DBDagBag().iter_all_latest_version_dags(session=session)
|
|
126
126
|
else:
|
|
127
|
-
|
|
127
|
+
try:
|
|
128
|
+
from airflow.models.dagbag import DagBag
|
|
129
|
+
except (ImportError, AttributeError):
|
|
130
|
+
DagBag = None
|
|
128
131
|
|
|
129
132
|
def _iter_dags() -> Iterable[DAG | SerializedDAG]:
|
|
130
|
-
|
|
131
|
-
|
|
133
|
+
if DagBag is None:
|
|
134
|
+
return []
|
|
135
|
+
dagbag = DagBag(read_dags_from_db=True)
|
|
136
|
+
if hasattr(dagbag, "collect_dags_from_db"):
|
|
137
|
+
dagbag.collect_dags_from_db()
|
|
132
138
|
return dagbag.dags.values()
|
|
133
139
|
|
|
134
140
|
|
|
@@ -727,6 +733,10 @@ class FabAirflowSecurityManagerOverride(AirflowSecurityManagerV2):
|
|
|
727
733
|
"""The JMESPATH role to use for user registration."""
|
|
728
734
|
return current_app.config["AUTH_USER_REGISTRATION_ROLE_JMESPATH"]
|
|
729
735
|
|
|
736
|
+
@property
|
|
737
|
+
def auth_remote_user_env_var(self) -> str:
|
|
738
|
+
return current_app.config["AUTH_REMOTE_USER_ENV_VAR"]
|
|
739
|
+
|
|
730
740
|
@property
|
|
731
741
|
def auth_username_ci(self):
|
|
732
742
|
"""Get the auth username for CI."""
|
|
@@ -1039,7 +1049,7 @@ class FabAirflowSecurityManagerOverride(AirflowSecurityManagerV2):
|
|
|
1039
1049
|
self.remove_permission_from_role(role, perm)
|
|
1040
1050
|
|
|
1041
1051
|
# Adding the access control permissions
|
|
1042
|
-
for rolename,
|
|
1052
|
+
for rolename, resource_actions_raw in access_control.items():
|
|
1043
1053
|
role = self.find_role(rolename)
|
|
1044
1054
|
if not role:
|
|
1045
1055
|
raise AirflowException(
|
|
@@ -1047,9 +1057,12 @@ class FabAirflowSecurityManagerOverride(AirflowSecurityManagerV2):
|
|
|
1047
1057
|
f"'{rolename}', but that role does not exist"
|
|
1048
1058
|
)
|
|
1049
1059
|
|
|
1050
|
-
|
|
1051
|
-
|
|
1052
|
-
|
|
1060
|
+
# Support for old-style access_control where only the actions are specified
|
|
1061
|
+
resource_actions = (
|
|
1062
|
+
resource_actions_raw
|
|
1063
|
+
if isinstance(resource_actions_raw, dict)
|
|
1064
|
+
else {permissions.RESOURCE_DAG: set(resource_actions_raw)}
|
|
1065
|
+
)
|
|
1053
1066
|
|
|
1054
1067
|
for resource_name, actions in resource_actions.items():
|
|
1055
1068
|
if resource_name not in self.RESOURCE_DETAILS_MAP:
|
|
@@ -1643,7 +1656,7 @@ class FabAirflowSecurityManagerOverride(AirflowSecurityManagerV2):
|
|
|
1643
1656
|
return perm
|
|
1644
1657
|
resource = self.create_resource(resource_name)
|
|
1645
1658
|
if resource is None:
|
|
1646
|
-
log.error(const.LOGMSG_ERR_SEC_ADD_PERMVIEW,
|
|
1659
|
+
log.error(const.LOGMSG_ERR_SEC_ADD_PERMVIEW, "Resource creation failed %s", resource_name)
|
|
1647
1660
|
return None
|
|
1648
1661
|
action = self.create_action(action_name)
|
|
1649
1662
|
perm = self.permission_model()
|
|
@@ -2204,6 +2217,36 @@ class FabAirflowSecurityManagerOverride(AirflowSecurityManagerV2):
|
|
|
2204
2217
|
# decode - if empty string, default to fallback, otherwise take first element
|
|
2205
2218
|
return raw_value[0].decode("utf-8") or fallback
|
|
2206
2219
|
|
|
2220
|
+
def auth_user_remote_user(self, username):
|
|
2221
|
+
"""
|
|
2222
|
+
REMOTE_USER user Authentication.
|
|
2223
|
+
|
|
2224
|
+
:param username: user's username for remote auth
|
|
2225
|
+
"""
|
|
2226
|
+
user = self.find_user(username=username)
|
|
2227
|
+
|
|
2228
|
+
# User does not exist, create one if auto user registration.
|
|
2229
|
+
if user is None and self.auth_user_registration:
|
|
2230
|
+
user = self.add_user(
|
|
2231
|
+
# All we have is REMOTE_USER, so we set
|
|
2232
|
+
# the other fields to blank.
|
|
2233
|
+
username=username,
|
|
2234
|
+
first_name=username,
|
|
2235
|
+
last_name="-",
|
|
2236
|
+
email=username + "@email.notfound",
|
|
2237
|
+
role=self.find_role(self.auth_user_registration_role),
|
|
2238
|
+
)
|
|
2239
|
+
|
|
2240
|
+
# If user does not exist on the DB and not auto user registration,
|
|
2241
|
+
# or user is inactive, go away.
|
|
2242
|
+
elif user is None or (not user.is_active):
|
|
2243
|
+
log.info(LOGMSG_WAR_SEC_LOGIN_FAILED, username)
|
|
2244
|
+
return None
|
|
2245
|
+
|
|
2246
|
+
self._rotate_session_id()
|
|
2247
|
+
self.update_user_auth_stat(user)
|
|
2248
|
+
return user
|
|
2249
|
+
|
|
2207
2250
|
"""
|
|
2208
2251
|
---------------
|
|
2209
2252
|
Private methods
|