apache-airflow-providers-fab 2.0.0rc2__py3-none-any.whl → 2.0.0rc4__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 +2 -2
- airflow/providers/fab/auth_manager/cli_commands/utils.py +1 -1
- airflow/providers/fab/auth_manager/fab_auth_manager.py +5 -2
- airflow/providers/fab/auth_manager/security_manager/override.py +132 -1
- airflow/providers/fab/get_provider_info.py +22 -37
- airflow/providers/fab/www/app.py +8 -0
- airflow/providers/fab/www/extensions/init_appbuilder.py +4 -0
- airflow/providers/fab/www/extensions/init_session.py +2 -2
- airflow/providers/fab/www/package-lock.json +20 -732
- airflow/providers/fab/www/package.json +3 -3
- airflow/providers/fab/www/utils.py +16 -0
- airflow/providers/fab/www/views.py +1 -1
- {apache_airflow_providers_fab-2.0.0rc2.dist-info → apache_airflow_providers_fab-2.0.0rc4.dist-info}/METADATA +5 -3
- {apache_airflow_providers_fab-2.0.0rc2.dist-info → apache_airflow_providers_fab-2.0.0rc4.dist-info}/RECORD +16 -16
- {apache_airflow_providers_fab-2.0.0rc2.dist-info → apache_airflow_providers_fab-2.0.0rc4.dist-info}/WHEEL +1 -1
- {apache_airflow_providers_fab-2.0.0rc2.dist-info → apache_airflow_providers_fab-2.0.0rc4.dist-info}/entry_points.txt +0 -0
@@ -32,8 +32,8 @@ __all__ = ["__version__"]
|
|
32
32
|
__version__ = "2.0.0"
|
33
33
|
|
34
34
|
if packaging.version.parse(packaging.version.parse(airflow_version).base_version) < packaging.version.parse(
|
35
|
-
"3.0.0
|
35
|
+
"3.0.0"
|
36
36
|
):
|
37
37
|
raise RuntimeError(
|
38
|
-
f"The package `apache-airflow-providers-fab:{__version__}` needs Apache Airflow 3.0.0
|
38
|
+
f"The package `apache-airflow-providers-fab:{__version__}` needs Apache Airflow 3.0.0+"
|
39
39
|
)
|
@@ -51,7 +51,7 @@ def _return_appbuilder(app: Flask) -> AirflowAppBuilder:
|
|
51
51
|
def get_application_builder() -> Generator[AirflowAppBuilder, None, None]:
|
52
52
|
static_folder = os.path.join(os.path.dirname(airflow.__file__), "www", "static")
|
53
53
|
flask_app = Flask(__name__, static_folder=static_folder)
|
54
|
-
webserver_config = conf.get_mandatory_value("
|
54
|
+
webserver_config = conf.get_mandatory_value("fab", "config_file")
|
55
55
|
with flask_app.app_context():
|
56
56
|
# Enable customizations in webserver_config.py to be applied via Flask.current_app.
|
57
57
|
flask_app.config.from_pyfile(webserver_config, silent=True)
|
@@ -155,7 +155,7 @@ _MAP_ACCESS_VIEW_TO_FAB_RESOURCE_TYPE = {
|
|
155
155
|
|
156
156
|
_MAP_MENU_ITEM_TO_FAB_RESOURCE_TYPE = {
|
157
157
|
MenuItem.ASSETS: RESOURCE_ASSET,
|
158
|
-
MenuItem.
|
158
|
+
MenuItem.AUDIT_LOG: RESOURCE_AUDIT_LOG,
|
159
159
|
MenuItem.CONNECTIONS: RESOURCE_CONNECTION,
|
160
160
|
MenuItem.DAGS: RESOURCE_DAG,
|
161
161
|
MenuItem.DOCS: RESOURCE_DOCS,
|
@@ -181,7 +181,7 @@ class FabAuthManager(BaseAuthManager[User]):
|
|
181
181
|
|
182
182
|
@cached_property
|
183
183
|
def apiserver_endpoint(self) -> str:
|
184
|
-
return conf.get("api", "base_url")
|
184
|
+
return conf.get("api", "base_url", fallback="/")
|
185
185
|
|
186
186
|
@staticmethod
|
187
187
|
def get_cli_commands() -> list[CLICommand]:
|
@@ -647,6 +647,9 @@ class FabAuthManager(BaseAuthManager[User]):
|
|
647
647
|
|
648
648
|
:meta private:
|
649
649
|
"""
|
650
|
+
# If the user gets deleted while being logged in
|
651
|
+
if not user:
|
652
|
+
return []
|
650
653
|
return getattr(user, "perms") or []
|
651
654
|
|
652
655
|
def _get_root_dag_id(self, dag_id: str) -> str:
|
@@ -704,6 +704,11 @@ class FabAirflowSecurityManagerOverride(AirflowSecurityManagerV2):
|
|
704
704
|
"""The mapping of auth roles."""
|
705
705
|
return self.appbuilder.get_app.config["AUTH_ROLES_MAPPING"]
|
706
706
|
|
707
|
+
@property
|
708
|
+
def auth_user_registration_role_jmespath(self) -> str:
|
709
|
+
"""The JMESPATH role to use for user registration."""
|
710
|
+
return self.appbuilder.get_app.config["AUTH_USER_REGISTRATION_ROLE_JMESPATH"]
|
711
|
+
|
707
712
|
@property
|
708
713
|
def auth_username_ci(self):
|
709
714
|
"""Get the auth username for CI."""
|
@@ -729,6 +734,10 @@ class FabAirflowSecurityManagerOverride(AirflowSecurityManagerV2):
|
|
729
734
|
"""Get the admin role."""
|
730
735
|
return self.appbuilder.get_app.config["AUTH_ROLE_ADMIN"]
|
731
736
|
|
737
|
+
@property
|
738
|
+
def oauth_whitelists(self):
|
739
|
+
return self.oauth_allow_list
|
740
|
+
|
732
741
|
def create_builtin_roles(self):
|
733
742
|
"""Return FAB builtin roles."""
|
734
743
|
return self.appbuilder.get_app.config.get("FAB_ROLES", {})
|
@@ -1933,6 +1942,100 @@ class FabAirflowSecurityManagerOverride(AirflowSecurityManagerV2):
|
|
1933
1942
|
log.info(LOGMSG_WAR_SEC_LOGIN_FAILED, username)
|
1934
1943
|
return None
|
1935
1944
|
|
1945
|
+
def set_oauth_session(self, provider, oauth_response):
|
1946
|
+
"""Set the current session with OAuth user secrets."""
|
1947
|
+
# Get this provider key names for token_key and token_secret
|
1948
|
+
token_key = self.get_oauth_token_key_name(provider)
|
1949
|
+
token_secret = self.get_oauth_token_secret_name(provider)
|
1950
|
+
# Save users token on encrypted session cookie
|
1951
|
+
session["oauth"] = (
|
1952
|
+
oauth_response[token_key],
|
1953
|
+
oauth_response.get(token_secret, ""),
|
1954
|
+
)
|
1955
|
+
session["oauth_provider"] = provider
|
1956
|
+
|
1957
|
+
def get_oauth_token_key_name(self, provider):
|
1958
|
+
"""
|
1959
|
+
Return the token_key name for the oauth provider.
|
1960
|
+
|
1961
|
+
If none is configured defaults to oauth_token
|
1962
|
+
this is configured using OAUTH_PROVIDERS and token_key key.
|
1963
|
+
"""
|
1964
|
+
for _provider in self.oauth_providers:
|
1965
|
+
if _provider["name"] == provider:
|
1966
|
+
return _provider.get("token_key", "oauth_token")
|
1967
|
+
|
1968
|
+
def get_oauth_token_secret_name(self, provider):
|
1969
|
+
"""
|
1970
|
+
Get the ``token_secret`` name for the oauth provider.
|
1971
|
+
|
1972
|
+
If none is configured, defaults to ``oauth_secret``. This is configured
|
1973
|
+
using ``OAUTH_PROVIDERS`` and ``token_secret``.
|
1974
|
+
"""
|
1975
|
+
for _provider in self.oauth_providers:
|
1976
|
+
if _provider["name"] == provider:
|
1977
|
+
return _provider.get("token_secret", "oauth_token_secret")
|
1978
|
+
|
1979
|
+
def auth_user_oauth(self, userinfo):
|
1980
|
+
"""
|
1981
|
+
Authenticate user with OAuth.
|
1982
|
+
|
1983
|
+
:userinfo: dict with user information
|
1984
|
+
(keys are the same as User model columns)
|
1985
|
+
"""
|
1986
|
+
# extract the username from `userinfo`
|
1987
|
+
if "username" in userinfo:
|
1988
|
+
username = userinfo["username"]
|
1989
|
+
elif "email" in userinfo:
|
1990
|
+
username = userinfo["email"]
|
1991
|
+
else:
|
1992
|
+
log.error("OAUTH userinfo does not have username or email %s", userinfo)
|
1993
|
+
return None
|
1994
|
+
|
1995
|
+
# If username is empty, go away
|
1996
|
+
if (username is None) or username == "":
|
1997
|
+
return None
|
1998
|
+
|
1999
|
+
# Search the DB for this user
|
2000
|
+
user = self.find_user(username=username)
|
2001
|
+
|
2002
|
+
# If user is not active, go away
|
2003
|
+
if user and (not user.is_active):
|
2004
|
+
return None
|
2005
|
+
|
2006
|
+
# If user is not registered, and not self-registration, go away
|
2007
|
+
if (not user) and (not self.auth_user_registration):
|
2008
|
+
return None
|
2009
|
+
|
2010
|
+
# Sync the user's roles
|
2011
|
+
if user and self.auth_roles_sync_at_login:
|
2012
|
+
user.roles = self._oauth_calculate_user_roles(userinfo)
|
2013
|
+
log.debug("Calculated new roles for user=%r as: %s", username, user.roles)
|
2014
|
+
|
2015
|
+
# If the user is new, register them
|
2016
|
+
if (not user) and self.auth_user_registration:
|
2017
|
+
user = self.add_user(
|
2018
|
+
username=username,
|
2019
|
+
first_name=userinfo.get("first_name", ""),
|
2020
|
+
last_name=userinfo.get("last_name", ""),
|
2021
|
+
email=userinfo.get("email", "") or f"{username}@email.notfound",
|
2022
|
+
role=self._oauth_calculate_user_roles(userinfo),
|
2023
|
+
)
|
2024
|
+
log.debug("New user registered: %s", user)
|
2025
|
+
|
2026
|
+
# If user registration failed, go away
|
2027
|
+
if not user:
|
2028
|
+
log.error("Error creating a new OAuth user %s", username)
|
2029
|
+
return None
|
2030
|
+
|
2031
|
+
# LOGIN SUCCESS (only if user is now registered)
|
2032
|
+
if user:
|
2033
|
+
self._rotate_session_id()
|
2034
|
+
self.update_user_auth_stat(user)
|
2035
|
+
return user
|
2036
|
+
else:
|
2037
|
+
return None
|
2038
|
+
|
1936
2039
|
def get_oauth_user_info(self, provider: str, resp: dict[str, Any]) -> dict[str, Any]:
|
1937
2040
|
"""
|
1938
2041
|
There are different OAuth APIs with different ways to retrieve user info.
|
@@ -2079,7 +2182,7 @@ class FabAirflowSecurityManagerOverride(AirflowSecurityManagerV2):
|
|
2079
2182
|
We need to do this upon successful authentication when using the
|
2080
2183
|
database session backend.
|
2081
2184
|
"""
|
2082
|
-
if conf.get("
|
2185
|
+
if conf.get("fab", "SESSION_BACKEND") == "database":
|
2083
2186
|
session.sid = str(uuid.uuid4())
|
2084
2187
|
|
2085
2188
|
def _get_microsoft_jwks(self) -> list[dict[str, Any]]:
|
@@ -2272,3 +2375,31 @@ class FabAirflowSecurityManagerOverride(AirflowSecurityManagerV2):
|
|
2272
2375
|
flash(Markup(text), level)
|
2273
2376
|
else:
|
2274
2377
|
getattr(log, level)(text.replace("<br>", "\n").replace("<b>", "*").replace("</b>", "*"))
|
2378
|
+
|
2379
|
+
def _oauth_calculate_user_roles(self, userinfo) -> list[str]:
|
2380
|
+
user_role_objects = set()
|
2381
|
+
|
2382
|
+
# apply AUTH_ROLES_MAPPING
|
2383
|
+
if self.auth_roles_mapping:
|
2384
|
+
user_role_keys = userinfo.get("role_keys", [])
|
2385
|
+
user_role_objects.update(self.get_roles_from_keys(user_role_keys))
|
2386
|
+
|
2387
|
+
# apply AUTH_USER_REGISTRATION_ROLE
|
2388
|
+
if self.auth_user_registration:
|
2389
|
+
registration_role_name = self.auth_user_registration_role
|
2390
|
+
|
2391
|
+
# if AUTH_USER_REGISTRATION_ROLE_JMESPATH is set,
|
2392
|
+
# use it for the registration role
|
2393
|
+
if self.auth_user_registration_role_jmespath:
|
2394
|
+
import jmespath
|
2395
|
+
|
2396
|
+
registration_role_name = jmespath.search(self.auth_user_registration_role_jmespath, userinfo)
|
2397
|
+
|
2398
|
+
# lookup registration role in flask db
|
2399
|
+
fab_role = self.find_role(registration_role_name)
|
2400
|
+
if fab_role:
|
2401
|
+
user_role_objects.add(fab_role)
|
2402
|
+
else:
|
2403
|
+
log.warning("Can't find AUTH_USER_REGISTRATION role: %s", registration_role_name)
|
2404
|
+
|
2405
|
+
return list(user_role_objects)
|
@@ -26,27 +26,6 @@ def get_provider_info():
|
|
26
26
|
"package-name": "apache-airflow-providers-fab",
|
27
27
|
"name": "Fab",
|
28
28
|
"description": "`Flask App Builder <https://flask-appbuilder.readthedocs.io/>`__\n",
|
29
|
-
"state": "not-ready",
|
30
|
-
"source-date-epoch": 1741121873,
|
31
|
-
"versions": [
|
32
|
-
"2.0.0",
|
33
|
-
"1.5.2",
|
34
|
-
"1.5.1",
|
35
|
-
"1.5.0",
|
36
|
-
"1.4.1",
|
37
|
-
"1.4.0",
|
38
|
-
"1.3.0",
|
39
|
-
"1.2.2",
|
40
|
-
"1.2.1",
|
41
|
-
"1.2.0",
|
42
|
-
"1.1.1",
|
43
|
-
"1.1.0",
|
44
|
-
"1.0.4",
|
45
|
-
"1.0.3",
|
46
|
-
"1.0.2",
|
47
|
-
"1.0.1",
|
48
|
-
"1.0.0",
|
49
|
-
],
|
50
29
|
"config": {
|
51
30
|
"fab": {
|
52
31
|
"description": "This section contains configs specific to FAB provider.",
|
@@ -74,28 +53,34 @@ def get_provider_info():
|
|
74
53
|
},
|
75
54
|
"auth_backends": {
|
76
55
|
"description": "Comma separated list of auth backends to authenticate users of the API.\n",
|
77
|
-
"version_added": "2.
|
56
|
+
"version_added": "2.0.0",
|
78
57
|
"type": "string",
|
79
58
|
"example": None,
|
80
59
|
"default": "airflow.providers.fab.auth_manager.api.auth.backend.session",
|
81
60
|
},
|
61
|
+
"config_file": {
|
62
|
+
"description": "Path of webserver config file used for configuring the webserver parameters\n",
|
63
|
+
"version_added": "2.0.0",
|
64
|
+
"type": "string",
|
65
|
+
"example": None,
|
66
|
+
"default": "{AIRFLOW_HOME}/webserver_config.py",
|
67
|
+
},
|
68
|
+
"session_backend": {
|
69
|
+
"description": "The type of backend used to store web session data, can be ``database`` or ``securecookie``. For the\n``database`` backend, sessions are store in the database and they can be\nmanaged there (for example when you reset password of the user, all sessions for that user are\ndeleted). For the ``securecookie`` backend, sessions are stored in encrypted cookies on the client\nside. The ``securecookie`` mechanism is 'lighter' than database backend, but sessions are not\ndeleted when you reset password of the user, which means that other than waiting for expiry time,\nthe only way to invalidate all sessions for a user is to change secret_key and restart webserver\n(which also invalidates and logs out all other user's sessions).\n\nWhen you are using ``database`` backend, make sure to keep your database session table small\nby periodically running ``airflow db clean --table session`` command, especially if you have\nautomated API calls that will create a new session for each call rather than reuse the sessions\nstored in browser cookies.\n",
|
70
|
+
"version_added": "2.0.0",
|
71
|
+
"type": "string",
|
72
|
+
"example": "securecookie",
|
73
|
+
"default": "database",
|
74
|
+
},
|
75
|
+
"session_lifetime_minutes": {
|
76
|
+
"description": "The UI cookie lifetime in minutes. User will be logged out from UI after\n``[fab] session_lifetime_minutes`` of non-activity\n",
|
77
|
+
"version_added": "2.0.0",
|
78
|
+
"type": "integer",
|
79
|
+
"example": None,
|
80
|
+
"default": "43200",
|
81
|
+
},
|
82
82
|
},
|
83
83
|
}
|
84
84
|
},
|
85
85
|
"auth-managers": ["airflow.providers.fab.auth_manager.fab_auth_manager.FabAuthManager"],
|
86
|
-
"dependencies": [
|
87
|
-
"apache-airflow>=3.0.0.dev0",
|
88
|
-
"apache-airflow-providers-common-compat>=1.2.1",
|
89
|
-
"blinker>=1.6.2",
|
90
|
-
"flask>=2.2.1,<2.3",
|
91
|
-
"flask-appbuilder==4.5.3",
|
92
|
-
"flask-login>=0.6.2",
|
93
|
-
"flask-session>=0.4.0,<0.6",
|
94
|
-
"flask-wtf>=1.1.0",
|
95
|
-
"connexion[flask]>=2.14.2,<3.0",
|
96
|
-
"jmespath>=0.7.0",
|
97
|
-
"werkzeug>=2.2,<4",
|
98
|
-
],
|
99
|
-
"optional-dependencies": {"kerberos": ["kerberos>=1.3.0"]},
|
100
|
-
"devel-dependencies": ["kerberos>=1.3.0", "requests_kerberos>=0.14.0"],
|
101
86
|
}
|
airflow/providers/fab/www/app.py
CHANGED
@@ -17,6 +17,7 @@
|
|
17
17
|
# under the License.
|
18
18
|
from __future__ import annotations
|
19
19
|
|
20
|
+
from datetime import timedelta
|
20
21
|
from os.path import isabs
|
21
22
|
|
22
23
|
from flask import Flask
|
@@ -40,6 +41,7 @@ from airflow.providers.fab.www.extensions.init_views import (
|
|
40
41
|
init_error_handlers,
|
41
42
|
init_plugins,
|
42
43
|
)
|
44
|
+
from airflow.providers.fab.www.utils import get_session_lifetime_config
|
43
45
|
|
44
46
|
app: Flask | None = None
|
45
47
|
|
@@ -56,6 +58,12 @@ def create_app(enable_plugins: bool):
|
|
56
58
|
flask_app.secret_key = conf.get("webserver", "SECRET_KEY")
|
57
59
|
flask_app.config["SQLALCHEMY_DATABASE_URI"] = conf.get("database", "SQL_ALCHEMY_CONN")
|
58
60
|
flask_app.config["SQLALCHEMY_TRACK_MODIFICATIONS"] = False
|
61
|
+
flask_app.config["PERMANENT_SESSION_LIFETIME"] = timedelta(minutes=get_session_lifetime_config())
|
62
|
+
|
63
|
+
webserver_config = conf.get_mandatory_value("fab", "config_file")
|
64
|
+
# Enable customizations in webserver_config.py to be applied via Flask.current_app.
|
65
|
+
with flask_app.app_context():
|
66
|
+
flask_app.config.from_pyfile(webserver_config, silent=True)
|
59
67
|
|
60
68
|
url = make_url(flask_app.config["SQLALCHEMY_DATABASE_URI"])
|
61
69
|
if url.drivername == "sqlite" and url.database and not isabs(url.database):
|
@@ -533,6 +533,10 @@ class AirflowAppBuilder:
|
|
533
533
|
def get_url_for_login_with(self, next_url: str | None = None) -> str:
|
534
534
|
return get_auth_manager().get_url_login(next_url=next_url)
|
535
535
|
|
536
|
+
@property
|
537
|
+
def get_url_for_login(self):
|
538
|
+
return get_auth_manager().get_url_login()
|
539
|
+
|
536
540
|
def get_url_for_locale(self, lang):
|
537
541
|
return url_for(
|
538
542
|
f"{self.bm.locale_view.endpoint}.{self.bm.locale_view.default_view}",
|
@@ -29,7 +29,7 @@ from airflow.providers.fab.www.session import (
|
|
29
29
|
def init_airflow_session_interface(app):
|
30
30
|
"""Set airflow session interface."""
|
31
31
|
config = app.config.copy()
|
32
|
-
selected_backend = conf.get("
|
32
|
+
selected_backend = conf.get("fab", "SESSION_BACKEND")
|
33
33
|
# A bit of a misnomer - normally cookies expire whenever the browser is closed
|
34
34
|
# or when they hit their expiry datetime, whichever comes first. "Permanent"
|
35
35
|
# cookies only expire when they hit their expiry datetime, and can outlive
|
@@ -59,6 +59,6 @@ def init_airflow_session_interface(app):
|
|
59
59
|
else:
|
60
60
|
raise AirflowConfigException(
|
61
61
|
"Unrecognized session backend specified in "
|
62
|
-
f"
|
62
|
+
f"[fab] session_backend: '{selected_backend}'. Please set "
|
63
63
|
"this to either 'database' or 'securecookie'."
|
64
64
|
)
|