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.
@@ -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.dev0"
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.dev0+"
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("webserver", "config_file")
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.ASSET_EVENTS: RESOURCE_ASSET,
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("webserver", "SESSION_BACKEND") == "database":
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.3.0",
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
  }
@@ -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("webserver", "SESSION_BACKEND")
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"web_server_session_backend: '{selected_backend}'. Please set "
62
+ f"[fab] session_backend: '{selected_backend}'. Please set "
63
63
  "this to either 'database' or 'securecookie'."
64
64
  )