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.
Files changed (48) hide show
  1. airflow/providers/fab/__init__.py +1 -1
  2. airflow/providers/fab/auth_manager/api_endpoints/user_endpoint.py +2 -2
  3. airflow/providers/fab/auth_manager/api_fastapi/datamodels/login.py +0 -7
  4. airflow/providers/fab/auth_manager/api_fastapi/datamodels/roles.py +63 -0
  5. airflow/providers/fab/auth_manager/api_fastapi/openapi/v2-fab-auth-manager-generated.yaml +416 -16
  6. airflow/providers/fab/auth_manager/api_fastapi/parameters.py +55 -0
  7. airflow/providers/fab/auth_manager/api_fastapi/routes/login.py +37 -5
  8. airflow/providers/fab/auth_manager/api_fastapi/routes/roles.py +137 -0
  9. airflow/providers/fab/auth_manager/api_fastapi/security.py +32 -0
  10. airflow/providers/fab/auth_manager/api_fastapi/services/login.py +12 -25
  11. airflow/providers/fab/auth_manager/api_fastapi/services/roles.py +158 -0
  12. airflow/providers/fab/auth_manager/api_fastapi/sorting.py +49 -0
  13. airflow/providers/fab/auth_manager/fab_auth_manager.py +33 -3
  14. airflow/providers/fab/auth_manager/models/__init__.py +3 -8
  15. airflow/providers/fab/auth_manager/models/db.py +1 -1
  16. airflow/providers/fab/auth_manager/security_manager/override.py +52 -9
  17. airflow/providers/fab/www/api_connexion/parameters.py +1 -46
  18. airflow/providers/fab/www/app.py +13 -10
  19. airflow/providers/fab/www/extensions/init_appbuilder.py +5 -2
  20. airflow/providers/fab/www/extensions/init_security.py +1 -1
  21. airflow/providers/fab/www/package-lock.json +315 -217
  22. airflow/providers/fab/www/package.json +9 -9
  23. airflow/providers/fab/www/session.py +5 -8
  24. airflow/providers/fab/www/static/dist/{743.935ed3d26e56ed8f63d3.js → 743.0c0bf201ae17e66a9a3f.js} +1 -1
  25. airflow/providers/fab/www/static/dist/{main.3cf3be1a0c5439bb640d.js → main.bc1f701c3d133e2a3bab.js} +1 -1
  26. airflow/providers/fab/www/static/dist/manifest.json +13 -13
  27. airflow/providers/fab/www/views.py +18 -14
  28. {apache_airflow_providers_fab-3.0.1.dist-info → apache_airflow_providers_fab-3.1.0rc1.dist-info}/METADATA +15 -14
  29. {apache_airflow_providers_fab-3.0.1.dist-info → apache_airflow_providers_fab-3.1.0rc1.dist-info}/RECORD +48 -42
  30. /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
  31. /airflow/providers/fab/www/static/dist/{743.935ed3d26e56ed8f63d3.js.LICENSE.txt → 743.0c0bf201ae17e66a9a3f.js.LICENSE.txt} +0 -0
  32. /airflow/providers/fab/www/static/dist/{airflowDefaultTheme.ff5a35f322070b094aa2.css → airflowDefaultTheme.ef6fc04c9b6920cd75c9.css} +0 -0
  33. /airflow/providers/fab/www/static/dist/{airflowDefaultTheme.ff5a35f322070b094aa2.js → airflowDefaultTheme.ef6fc04c9b6920cd75c9.js} +0 -0
  34. /airflow/providers/fab/www/static/dist/{flash.5583a9e0cf11f2be93da.css → flash.eaaf777ec1b3628cf7be.css} +0 -0
  35. /airflow/providers/fab/www/static/dist/{flash.5583a9e0cf11f2be93da.js → flash.eaaf777ec1b3628cf7be.js} +0 -0
  36. /airflow/providers/fab/www/static/dist/{loadingDots.2e5f555f0753107b0300.css → loadingDots.76f4332c0a932c3dc08f.css} +0 -0
  37. /airflow/providers/fab/www/static/dist/{loadingDots.2e5f555f0753107b0300.js → loadingDots.76f4332c0a932c3dc08f.js} +0 -0
  38. /airflow/providers/fab/www/static/dist/{main.3cf3be1a0c5439bb640d.css → main.bc1f701c3d133e2a3bab.css} +0 -0
  39. /airflow/providers/fab/www/static/dist/{main.3cf3be1a0c5439bb640d.js.LICENSE.txt → main.bc1f701c3d133e2a3bab.js.LICENSE.txt} +0 -0
  40. /airflow/providers/fab/www/static/dist/{materialIcons.3e67dd6fbfcc4f3b5105.css → materialIcons.ad07a489b2f0fc1a96bf.css} +0 -0
  41. /airflow/providers/fab/www/static/dist/{materialIcons.3e67dd6fbfcc4f3b5105.js → materialIcons.ad07a489b2f0fc1a96bf.js} +0 -0
  42. /airflow/providers/fab/www/static/dist/{moment.9baee5ec3d7639a10897.js → moment.5b85b4f6be2fe9c405ac.js} +0 -0
  43. /airflow/providers/fab/www/static/dist/{runtime.6ad9da077ea169d60db9.js → runtime.254c277d91ce3ac79c64.js} +0 -0
  44. {apache_airflow_providers_fab-3.0.1.dist-info → apache_airflow_providers_fab-3.1.0rc1.dist-info}/WHEEL +0 -0
  45. {apache_airflow_providers_fab-3.0.1.dist-info → apache_airflow_providers_fab-3.1.0rc1.dist-info}/entry_points.txt +0 -0
  46. {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
  47. {airflow/providers/fab → apache_airflow_providers_fab-3.1.0rc1.dist-info/licenses}/LICENSE +0 -0
  48. {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 TYPE_CHECKING, cast
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 LoginBody, LoginResponse
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, body: LoginBody, expiration_time_in_seconds: int = conf.getint("api_auth", "jwt_expiration_time")
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
- if not body.username or not body.password:
43
- raise HTTPException(
44
- status_code=status.HTTP_400_BAD_REQUEST, detail="Username and password must be provided"
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, AirflowException
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.exceptions import AirflowException
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.exceptions import AirflowException
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
- from airflow.models.dagbag import DagBag # type: ignore[attr-defined, no-redef]
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
- dagbag = DagBag(read_dags_from_db=True) # type: ignore[call-arg]
131
- dagbag.collect_dags_from_db() # type: ignore[attr-defined]
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, resource_actions in access_control.items():
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
- if not isinstance(resource_actions, dict):
1051
- # Support for old-style access_control where only the actions are specified
1052
- resource_actions = {permissions.RESOURCE_DAG: set(resource_actions)}
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, f"Resource creation failed {resource_name}")
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