apache-airflow-providers-fab 3.0.1__py3-none-any.whl → 3.1.1rc1__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/cli_commands/permissions_command.py +6 -2
- 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 +60 -17
- airflow/providers/fab/version_compat.py +1 -0
- 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/extensions/init_views.py +11 -7
- airflow/providers/fab/www/package-lock.json +417 -265
- airflow/providers/fab/www/package.json +13 -10
- 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.1rc1.dist-info}/METADATA +15 -14
- {apache_airflow_providers_fab-3.0.1.dist-info → apache_airflow_providers_fab-3.1.1rc1.dist-info}/RECORD +51 -45
- /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.1rc1.dist-info}/WHEEL +0 -0
- {apache_airflow_providers_fab-3.0.1.dist-info → apache_airflow_providers_fab-3.1.1rc1.dist-info}/entry_points.txt +0 -0
- {apache_airflow_providers_fab-3.0.1.dist-info → apache_airflow_providers_fab-3.1.1rc1.dist-info}/licenses/3rd-party-licenses/LICENSES-ui.txt +0 -0
- {airflow/providers/fab → apache_airflow_providers_fab-3.1.1rc1.dist-info/licenses}/LICENSE +0 -0
- {apache_airflow_providers_fab-3.0.1.dist-info → apache_airflow_providers_fab-3.1.1rc1.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)
|
|
@@ -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
|
|
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,
|
|
@@ -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
|
|