apache-airflow-providers-fab 3.1.0rc1__py3-none-any.whl → 3.2.0rc1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- airflow/providers/fab/__init__.py +1 -1
- airflow/providers/fab/auth_manager/api_endpoints/user_endpoint.py +3 -1
- airflow/providers/fab/auth_manager/api_fastapi/datamodels/roles.py +13 -7
- airflow/providers/fab/auth_manager/api_fastapi/datamodels/users.py +68 -0
- airflow/providers/fab/auth_manager/api_fastapi/openapi/v2-fab-auth-manager-generated.yaml +485 -18
- airflow/providers/fab/auth_manager/api_fastapi/routes/login.py +2 -4
- airflow/providers/fab/auth_manager/api_fastapi/routes/users.py +133 -0
- airflow/providers/fab/auth_manager/api_fastapi/services/login.py +1 -2
- airflow/providers/fab/auth_manager/api_fastapi/services/users.py +219 -0
- airflow/providers/fab/auth_manager/cli_commands/db_command.py +2 -2
- airflow/providers/fab/auth_manager/cli_commands/permissions_command.py +6 -2
- airflow/providers/fab/auth_manager/cli_commands/user_command.py +3 -3
- airflow/providers/fab/auth_manager/fab_auth_manager.py +18 -51
- airflow/providers/fab/auth_manager/models/__init__.py +6 -6
- airflow/providers/fab/auth_manager/security_manager/override.py +97 -84
- airflow/providers/fab/auth_manager/views/user.py +12 -0
- airflow/providers/fab/cli/__init__.py +18 -0
- airflow/providers/fab/{auth_manager/cli_commands → cli}/definition.py +50 -2
- airflow/providers/fab/get_provider_info.py +8 -0
- airflow/providers/fab/version_compat.py +1 -0
- airflow/providers/fab/www/app.py +2 -7
- airflow/providers/fab/www/extensions/init_appbuilder.py +3 -2
- airflow/providers/fab/www/extensions/init_views.py +11 -7
- airflow/providers/fab/www/package-lock.json +764 -572
- airflow/providers/fab/www/package.json +12 -9
- airflow/providers/fab/www/static/dist/{743.0c0bf201ae17e66a9a3f.js → 743.8fb7d21632ed892227fe.js} +2 -2
- airflow/providers/fab/www/static/dist/{airflowDefaultTheme.ef6fc04c9b6920cd75c9.js → airflowDefaultTheme.51e5d14856ee1ebc83ca.js} +1 -1
- airflow/providers/fab/www/static/dist/{flash.eaaf777ec1b3628cf7be.js → flash.865b6940c00b2a9041b3.js} +1 -1
- airflow/providers/fab/www/static/dist/{loadingDots.76f4332c0a932c3dc08f.js → loadingDots.07f5b9805847242736e1.js} +1 -1
- airflow/providers/fab/www/static/dist/main.8cffe40bcf7cca998f4e.js +2 -0
- airflow/providers/fab/www/static/dist/manifest.json +13 -13
- airflow/providers/fab/www/static/dist/{materialIcons.ad07a489b2f0fc1a96bf.js → materialIcons.4fe84ae36604d84dec78.js} +1 -1
- airflow/providers/fab/www/static/dist/moment.0ec3ee3fb60dc999b1fd.js +1 -0
- airflow/providers/fab/www/static/js/main.js +11 -0
- airflow/providers/fab/www/templates/airflow/main.html +1 -0
- {apache_airflow_providers_fab-3.1.0rc1.dist-info → apache_airflow_providers_fab-3.2.0rc1.dist-info}/METADATA +10 -10
- {apache_airflow_providers_fab-3.1.0rc1.dist-info → apache_airflow_providers_fab-3.2.0rc1.dist-info}/RECORD +50 -46
- {apache_airflow_providers_fab-3.1.0rc1.dist-info → apache_airflow_providers_fab-3.2.0rc1.dist-info}/licenses/3rd-party-licenses/LICENSES-ui.txt +1 -1
- {apache_airflow_providers_fab-3.1.0rc1.dist-info → apache_airflow_providers_fab-3.2.0rc1.dist-info}/licenses/NOTICE +1 -1
- airflow/providers/fab/www/static/dist/main.bc1f701c3d133e2a3bab.js +0 -2
- airflow/providers/fab/www/static/dist/moment.5b85b4f6be2fe9c405ac.js +0 -1
- /airflow/providers/fab/www/static/dist/{743.0c0bf201ae17e66a9a3f.js.LICENSE.txt → 743.8fb7d21632ed892227fe.js.LICENSE.txt} +0 -0
- /airflow/providers/fab/www/static/dist/{airflowDefaultTheme.ef6fc04c9b6920cd75c9.css → airflowDefaultTheme.51e5d14856ee1ebc83ca.css} +0 -0
- /airflow/providers/fab/www/static/dist/{flash.eaaf777ec1b3628cf7be.css → flash.865b6940c00b2a9041b3.css} +0 -0
- /airflow/providers/fab/www/static/dist/{loadingDots.76f4332c0a932c3dc08f.css → loadingDots.07f5b9805847242736e1.css} +0 -0
- /airflow/providers/fab/www/static/dist/{main.bc1f701c3d133e2a3bab.css → main.8cffe40bcf7cca998f4e.css} +0 -0
- /airflow/providers/fab/www/static/dist/{main.bc1f701c3d133e2a3bab.js.LICENSE.txt → main.8cffe40bcf7cca998f4e.js.LICENSE.txt} +0 -0
- /airflow/providers/fab/www/static/dist/{materialIcons.ad07a489b2f0fc1a96bf.css → materialIcons.4fe84ae36604d84dec78.css} +0 -0
- /airflow/providers/fab/www/static/dist/{runtime.254c277d91ce3ac79c64.js → runtime.45b36fb8335446865b53.js} +0 -0
- {apache_airflow_providers_fab-3.1.0rc1.dist-info → apache_airflow_providers_fab-3.2.0rc1.dist-info}/WHEEL +0 -0
- {apache_airflow_providers_fab-3.1.0rc1.dist-info → apache_airflow_providers_fab-3.2.0rc1.dist-info}/entry_points.txt +0 -0
- {apache_airflow_providers_fab-3.1.0rc1.dist-info → apache_airflow_providers_fab-3.2.0rc1.dist-info}/licenses/LICENSE +0 -0
|
@@ -44,6 +44,7 @@ from flask_appbuilder.const import (
|
|
|
44
44
|
from flask_appbuilder.models.sqla.interface import SQLAInterface
|
|
45
45
|
from flask_appbuilder.security.api import SecurityApi
|
|
46
46
|
from flask_appbuilder.security.registerviews import (
|
|
47
|
+
BaseRegisterUser,
|
|
47
48
|
RegisterUserDBView,
|
|
48
49
|
RegisterUserOAuthView,
|
|
49
50
|
)
|
|
@@ -52,8 +53,10 @@ from flask_appbuilder.security.views import (
|
|
|
52
53
|
AuthLDAPView,
|
|
53
54
|
AuthOAuthView,
|
|
54
55
|
AuthRemoteUserView,
|
|
56
|
+
AuthView,
|
|
55
57
|
RegisterUserModelView,
|
|
56
58
|
UserGroupModelView,
|
|
59
|
+
UserModelView,
|
|
57
60
|
)
|
|
58
61
|
from flask_babel import lazy_gettext
|
|
59
62
|
from flask_jwt_extended import JWTManager
|
|
@@ -67,7 +70,6 @@ from sqlalchemy.orm import joinedload
|
|
|
67
70
|
from werkzeug.security import check_password_hash, generate_password_hash
|
|
68
71
|
|
|
69
72
|
from airflow.configuration import conf
|
|
70
|
-
from airflow.providers.common.compat.sdk import AirflowException
|
|
71
73
|
from airflow.providers.fab.auth_manager.models import (
|
|
72
74
|
Action,
|
|
73
75
|
Group,
|
|
@@ -101,15 +103,16 @@ from airflow.providers.fab.version_compat import AIRFLOW_V_3_1_PLUS
|
|
|
101
103
|
from airflow.providers.fab.www.security import permissions
|
|
102
104
|
from airflow.providers.fab.www.security_manager import AirflowSecurityManagerV2
|
|
103
105
|
from airflow.providers.fab.www.session import AirflowDatabaseSessionInterface
|
|
104
|
-
from airflow.security.permissions import RESOURCE_BACKFILL
|
|
105
106
|
|
|
106
107
|
if TYPE_CHECKING:
|
|
108
|
+
from authlib.integrations.flask_client import OAuth
|
|
109
|
+
|
|
107
110
|
from airflow.providers.fab.www.security.permissions import (
|
|
108
111
|
RESOURCE_ASSET,
|
|
109
112
|
RESOURCE_ASSET_ALIAS,
|
|
110
113
|
)
|
|
111
114
|
from airflow.sdk import DAG
|
|
112
|
-
from airflow.serialization.
|
|
115
|
+
from airflow.serialization.definitions.dag import SerializedDAG
|
|
113
116
|
else:
|
|
114
117
|
from airflow.providers.common.compat.security.permissions import (
|
|
115
118
|
RESOURCE_ASSET,
|
|
@@ -149,6 +152,10 @@ log = logging.getLogger(__name__)
|
|
|
149
152
|
MAX_NUM_DATABASE_USER_SESSIONS = 50000
|
|
150
153
|
|
|
151
154
|
|
|
155
|
+
class FabException(Exception):
|
|
156
|
+
"""Custom exception for FAB security manager."""
|
|
157
|
+
|
|
158
|
+
|
|
152
159
|
class FabAirflowSecurityManagerOverride(AirflowSecurityManagerV2):
|
|
153
160
|
"""
|
|
154
161
|
This security manager overrides the default AirflowSecurityManager security manager.
|
|
@@ -160,11 +167,11 @@ class FabAirflowSecurityManagerOverride(AirflowSecurityManagerV2):
|
|
|
160
167
|
:param appbuilder: The appbuilder.
|
|
161
168
|
"""
|
|
162
169
|
|
|
163
|
-
auth_view = None
|
|
170
|
+
auth_view: AuthView | None = None
|
|
164
171
|
""" The obj instance for authentication view """
|
|
165
|
-
registeruser_view = None
|
|
172
|
+
registeruser_view: BaseRegisterUser | None = None
|
|
166
173
|
""" The obj instance for registering user view """
|
|
167
|
-
user_view = None
|
|
174
|
+
user_view: type[UserModelView] | None = None
|
|
168
175
|
""" The obj instance for user view """
|
|
169
176
|
|
|
170
177
|
""" Models """
|
|
@@ -208,9 +215,9 @@ class FabAirflowSecurityManagerOverride(AirflowSecurityManagerV2):
|
|
|
208
215
|
security_api = SecurityApi
|
|
209
216
|
""" Override if you want your own Security API login endpoint """
|
|
210
217
|
|
|
211
|
-
jwt_manager = None
|
|
218
|
+
jwt_manager: JWTManager | None = None
|
|
212
219
|
""" Flask-JWT-Extended """
|
|
213
|
-
oauth = None
|
|
220
|
+
oauth: OAuth | None = None
|
|
214
221
|
oauth_remotes: dict[str, Any]
|
|
215
222
|
""" Initialized (remote_app) providers dict {'provider_name', OBJ } """
|
|
216
223
|
|
|
@@ -236,7 +243,7 @@ class FabAirflowSecurityManagerOverride(AirflowSecurityManagerV2):
|
|
|
236
243
|
(permissions.ACTION_CAN_READ, permissions.RESOURCE_DAG_WARNING),
|
|
237
244
|
(permissions.ACTION_CAN_READ, RESOURCE_ASSET),
|
|
238
245
|
(permissions.ACTION_CAN_READ, RESOURCE_ASSET_ALIAS),
|
|
239
|
-
(permissions.ACTION_CAN_READ, RESOURCE_BACKFILL),
|
|
246
|
+
(permissions.ACTION_CAN_READ, permissions.RESOURCE_BACKFILL),
|
|
240
247
|
(permissions.ACTION_CAN_READ, permissions.RESOURCE_CLUSTER_ACTIVITY),
|
|
241
248
|
(permissions.ACTION_CAN_READ, permissions.RESOURCE_POOL),
|
|
242
249
|
(permissions.ACTION_CAN_READ, permissions.RESOURCE_IMPORT_ERROR),
|
|
@@ -305,12 +312,14 @@ class FabAirflowSecurityManagerOverride(AirflowSecurityManagerV2):
|
|
|
305
312
|
(permissions.ACTION_CAN_READ, permissions.RESOURCE_VARIABLE),
|
|
306
313
|
(permissions.ACTION_CAN_EDIT, permissions.RESOURCE_VARIABLE),
|
|
307
314
|
(permissions.ACTION_CAN_DELETE, permissions.RESOURCE_VARIABLE),
|
|
315
|
+
(permissions.ACTION_CAN_CREATE, permissions.RESOURCE_XCOM),
|
|
316
|
+
(permissions.ACTION_CAN_EDIT, permissions.RESOURCE_XCOM),
|
|
308
317
|
(permissions.ACTION_CAN_DELETE, permissions.RESOURCE_XCOM),
|
|
309
318
|
(permissions.ACTION_CAN_CREATE, RESOURCE_ASSET),
|
|
310
319
|
(permissions.ACTION_CAN_DELETE, RESOURCE_ASSET),
|
|
311
|
-
(permissions.ACTION_CAN_CREATE, RESOURCE_BACKFILL),
|
|
312
|
-
(permissions.ACTION_CAN_EDIT, RESOURCE_BACKFILL),
|
|
313
|
-
(permissions.ACTION_CAN_DELETE, RESOURCE_BACKFILL),
|
|
320
|
+
(permissions.ACTION_CAN_CREATE, permissions.RESOURCE_BACKFILL),
|
|
321
|
+
(permissions.ACTION_CAN_EDIT, permissions.RESOURCE_BACKFILL),
|
|
322
|
+
(permissions.ACTION_CAN_DELETE, permissions.RESOURCE_BACKFILL),
|
|
314
323
|
]
|
|
315
324
|
# [END security_op_perms]
|
|
316
325
|
|
|
@@ -418,9 +427,9 @@ class FabAirflowSecurityManagerOverride(AirflowSecurityManagerV2):
|
|
|
418
427
|
log.warning("JWT token is not validated!")
|
|
419
428
|
return me
|
|
420
429
|
|
|
421
|
-
raise
|
|
430
|
+
raise FabException("OAuth signature verify failed")
|
|
422
431
|
|
|
423
|
-
def register_views(self):
|
|
432
|
+
def register_views(self) -> None:
|
|
424
433
|
"""Register FAB auth manager related views."""
|
|
425
434
|
if not current_app.config.get("FAB_ADD_SECURITY_VIEWS", True):
|
|
426
435
|
return
|
|
@@ -457,7 +466,7 @@ class FabAirflowSecurityManagerOverride(AirflowSecurityManagerV2):
|
|
|
457
466
|
|
|
458
467
|
# this needs to be done after the view is added, otherwise the blueprint
|
|
459
468
|
# is not initialized
|
|
460
|
-
if self.is_auth_limited:
|
|
469
|
+
if self.is_auth_limited and self.auth_view:
|
|
461
470
|
self.limiter.limit(self.auth_rate_limit, methods=["POST"])(self.auth_view.blueprint)
|
|
462
471
|
|
|
463
472
|
self.user_view = self.appbuilder.add_view(
|
|
@@ -480,14 +489,13 @@ class FabAirflowSecurityManagerOverride(AirflowSecurityManagerV2):
|
|
|
480
489
|
)
|
|
481
490
|
role_view.related_views = [self.user_view.__class__]
|
|
482
491
|
|
|
483
|
-
|
|
484
|
-
self.
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
)
|
|
492
|
+
self.appbuilder.add_view(
|
|
493
|
+
self.userstatschartview,
|
|
494
|
+
"User's Statistics",
|
|
495
|
+
icon="fa-bar-chart-o",
|
|
496
|
+
label=lazy_gettext("User's Statistics"),
|
|
497
|
+
category="Security",
|
|
498
|
+
)
|
|
491
499
|
if self.auth_user_registration:
|
|
492
500
|
self.appbuilder.add_view(
|
|
493
501
|
self.registerusermodelview,
|
|
@@ -534,7 +542,7 @@ class FabAirflowSecurityManagerOverride(AirflowSecurityManagerV2):
|
|
|
534
542
|
lm.user_loader(self.load_user)
|
|
535
543
|
return lm
|
|
536
544
|
|
|
537
|
-
def create_jwt_manager(self):
|
|
545
|
+
def create_jwt_manager(self) -> None:
|
|
538
546
|
"""Create the JWT manager."""
|
|
539
547
|
jwt_manager = JWTManager()
|
|
540
548
|
jwt_manager.init_app(current_app)
|
|
@@ -550,6 +558,8 @@ class FabAirflowSecurityManagerOverride(AirflowSecurityManagerV2):
|
|
|
550
558
|
:param password: the clear text password to reset and save hashed on the db
|
|
551
559
|
"""
|
|
552
560
|
user = self.get_user_by_id(userid)
|
|
561
|
+
if not user:
|
|
562
|
+
return False
|
|
553
563
|
user.password = generate_password_hash(password)
|
|
554
564
|
self.reset_user_sessions(user)
|
|
555
565
|
return self.update_user(user)
|
|
@@ -591,16 +601,17 @@ class FabAirflowSecurityManagerOverride(AirflowSecurityManagerV2):
|
|
|
591
601
|
"warning",
|
|
592
602
|
)
|
|
593
603
|
|
|
594
|
-
def load_user_jwt(self, _jwt_header, jwt_data):
|
|
604
|
+
def load_user_jwt(self, _jwt_header, jwt_data) -> User | None:
|
|
595
605
|
identity = jwt_data["sub"]
|
|
596
606
|
user = self.load_user(identity)
|
|
597
607
|
if user and user.is_active:
|
|
598
608
|
# Set flask g.user to JWT user, we can't do it on before request
|
|
599
609
|
g.user = user
|
|
600
610
|
return user
|
|
611
|
+
return None
|
|
601
612
|
|
|
602
613
|
@property
|
|
603
|
-
def auth_type(self):
|
|
614
|
+
def auth_type(self) -> int:
|
|
604
615
|
"""Get the auth type."""
|
|
605
616
|
return current_app.config["AUTH_TYPE"]
|
|
606
617
|
|
|
@@ -737,11 +748,6 @@ class FabAirflowSecurityManagerOverride(AirflowSecurityManagerV2):
|
|
|
737
748
|
def auth_remote_user_env_var(self) -> str:
|
|
738
749
|
return current_app.config["AUTH_REMOTE_USER_ENV_VAR"]
|
|
739
750
|
|
|
740
|
-
@property
|
|
741
|
-
def auth_username_ci(self):
|
|
742
|
-
"""Get the auth username for CI."""
|
|
743
|
-
return current_app.config.get("AUTH_USERNAME_CI", True)
|
|
744
|
-
|
|
745
751
|
@property
|
|
746
752
|
def auth_user_registration(self):
|
|
747
753
|
"""Will user self registration be allowed."""
|
|
@@ -763,15 +769,15 @@ class FabAirflowSecurityManagerOverride(AirflowSecurityManagerV2):
|
|
|
763
769
|
return current_app.config["AUTH_ROLE_ADMIN"]
|
|
764
770
|
|
|
765
771
|
@property
|
|
766
|
-
def oauth_whitelists(self):
|
|
772
|
+
def oauth_whitelists(self) -> dict[str, list]:
|
|
767
773
|
return self.oauth_allow_list
|
|
768
774
|
|
|
769
775
|
@staticmethod
|
|
770
|
-
def create_builtin_roles():
|
|
776
|
+
def create_builtin_roles() -> dict:
|
|
771
777
|
return current_app.config.get("FAB_ROLES", {})
|
|
772
778
|
|
|
773
779
|
@property
|
|
774
|
-
def builtin_roles(self):
|
|
780
|
+
def builtin_roles(self) -> dict:
|
|
775
781
|
"""Get the builtin roles."""
|
|
776
782
|
return self._builtin_roles
|
|
777
783
|
|
|
@@ -780,11 +786,11 @@ class FabAirflowSecurityManagerOverride(AirflowSecurityManagerV2):
|
|
|
780
786
|
return current_app.config["AUTH_API_LOGIN_ALLOW_MULTIPLE_PROVIDERS"]
|
|
781
787
|
|
|
782
788
|
@property
|
|
783
|
-
def auth_type_provider_name(self):
|
|
789
|
+
def auth_type_provider_name(self) -> str | None:
|
|
784
790
|
provider_to_auth_type = {AUTH_DB: "db", AUTH_LDAP: "ldap"}
|
|
785
791
|
return provider_to_auth_type.get(self.auth_type)
|
|
786
792
|
|
|
787
|
-
def _init_config(self):
|
|
793
|
+
def _init_config(self) -> None:
|
|
788
794
|
"""
|
|
789
795
|
Initialize config.
|
|
790
796
|
|
|
@@ -854,7 +860,7 @@ class FabAirflowSecurityManagerOverride(AirflowSecurityManagerV2):
|
|
|
854
860
|
current_app.config.setdefault("AUTH_RATE_LIMITED", True)
|
|
855
861
|
current_app.config.setdefault("AUTH_RATE_LIMIT", "5 per 40 second")
|
|
856
862
|
|
|
857
|
-
def _init_auth(self):
|
|
863
|
+
def _init_auth(self) -> None:
|
|
858
864
|
"""
|
|
859
865
|
Initialize authentication configuration.
|
|
860
866
|
|
|
@@ -877,7 +883,7 @@ class FabAirflowSecurityManagerOverride(AirflowSecurityManagerV2):
|
|
|
877
883
|
self.oauth_allow_list[provider_name] = provider["whitelist"]
|
|
878
884
|
self.oauth_remotes[provider_name] = obj_provider
|
|
879
885
|
|
|
880
|
-
def _init_data_model(self):
|
|
886
|
+
def _init_data_model(self) -> None:
|
|
881
887
|
user_data_model = SQLAInterface(self.user_model)
|
|
882
888
|
if self.auth_type == const.AUTH_DB:
|
|
883
889
|
self.userdbmodelview.datamodel = user_data_model
|
|
@@ -888,8 +894,7 @@ class FabAirflowSecurityManagerOverride(AirflowSecurityManagerV2):
|
|
|
888
894
|
elif self.auth_type == const.AUTH_REMOTE_USER:
|
|
889
895
|
self.userremoteusermodelview.datamodel = user_data_model
|
|
890
896
|
|
|
891
|
-
|
|
892
|
-
self.userstatschartview.datamodel = user_data_model
|
|
897
|
+
self.userstatschartview.datamodel = user_data_model
|
|
893
898
|
if self.auth_user_registration:
|
|
894
899
|
self.registerusermodelview.datamodel = SQLAInterface(self.registeruser_model)
|
|
895
900
|
|
|
@@ -899,7 +904,7 @@ class FabAirflowSecurityManagerOverride(AirflowSecurityManagerV2):
|
|
|
899
904
|
self.resourcemodelview.datamodel = SQLAInterface(self.resource_model)
|
|
900
905
|
self.permissionmodelview.datamodel = SQLAInterface(self.permission_model)
|
|
901
906
|
|
|
902
|
-
def create_db(self):
|
|
907
|
+
def create_db(self) -> None:
|
|
903
908
|
"""
|
|
904
909
|
Create the database.
|
|
905
910
|
|
|
@@ -1052,7 +1057,7 @@ class FabAirflowSecurityManagerOverride(AirflowSecurityManagerV2):
|
|
|
1052
1057
|
for rolename, resource_actions_raw in access_control.items():
|
|
1053
1058
|
role = self.find_role(rolename)
|
|
1054
1059
|
if not role:
|
|
1055
|
-
raise
|
|
1060
|
+
raise FabException(
|
|
1056
1061
|
f"The access_control mapping for DAG '{dag_id}' includes a role named "
|
|
1057
1062
|
f"'{rolename}', but that role does not exist"
|
|
1058
1063
|
)
|
|
@@ -1066,7 +1071,7 @@ class FabAirflowSecurityManagerOverride(AirflowSecurityManagerV2):
|
|
|
1066
1071
|
|
|
1067
1072
|
for resource_name, actions in resource_actions.items():
|
|
1068
1073
|
if resource_name not in self.RESOURCE_DETAILS_MAP:
|
|
1069
|
-
raise
|
|
1074
|
+
raise FabException(
|
|
1070
1075
|
f"The access_control map for DAG '{dag_id}' includes the following invalid "
|
|
1071
1076
|
f"resource name: '{resource_name}'; "
|
|
1072
1077
|
f"The set of valid resource names is: {self.RESOURCE_DETAILS_MAP.keys()}"
|
|
@@ -1078,7 +1083,7 @@ class FabAirflowSecurityManagerOverride(AirflowSecurityManagerV2):
|
|
|
1078
1083
|
invalid_actions = set(actions) - self.RESOURCE_DETAILS_MAP[resource_name]["actions"]
|
|
1079
1084
|
|
|
1080
1085
|
if invalid_actions:
|
|
1081
|
-
raise
|
|
1086
|
+
raise FabException(
|
|
1082
1087
|
f"The access_control map for DAG '{dag_resource_name}' includes "
|
|
1083
1088
|
f"the following invalid permissions: {invalid_actions}; "
|
|
1084
1089
|
f"The set of valid permissions is: {self.RESOURCE_DETAILS_MAP[resource_name]['actions']}"
|
|
@@ -1089,7 +1094,9 @@ class FabAirflowSecurityManagerOverride(AirflowSecurityManagerV2):
|
|
|
1089
1094
|
if dag_perm:
|
|
1090
1095
|
self.add_permission_to_role(role, dag_perm)
|
|
1091
1096
|
|
|
1092
|
-
def add_permissions_view(
|
|
1097
|
+
def add_permissions_view(
|
|
1098
|
+
self, base_action_names, resource_name
|
|
1099
|
+
) -> None: # Keep name for compatibility with FAB.
|
|
1093
1100
|
"""
|
|
1094
1101
|
Add an action on a resource to the backend.
|
|
1095
1102
|
|
|
@@ -1114,6 +1121,8 @@ class FabAirflowSecurityManagerOverride(AirflowSecurityManagerV2):
|
|
|
1114
1121
|
else:
|
|
1115
1122
|
# Permissions on this view exist but....
|
|
1116
1123
|
admin_role = self.find_role(self.auth_role_admin)
|
|
1124
|
+
if not admin_role:
|
|
1125
|
+
admin_role = self.add_role(self.auth_role_admin)
|
|
1117
1126
|
for action_name in base_action_names:
|
|
1118
1127
|
# Check if base view permissions exist
|
|
1119
1128
|
if not self.perms_include_action(perms, action_name):
|
|
@@ -1137,7 +1146,7 @@ class FabAirflowSecurityManagerOverride(AirflowSecurityManagerV2):
|
|
|
1137
1146
|
# Role Admin must have all permissions
|
|
1138
1147
|
self.add_permission_to_role(admin_role, perm)
|
|
1139
1148
|
|
|
1140
|
-
def add_permissions_menu(self, resource_name):
|
|
1149
|
+
def add_permissions_menu(self, resource_name) -> None:
|
|
1141
1150
|
"""
|
|
1142
1151
|
Add menu_access to resource on permission_resource.
|
|
1143
1152
|
|
|
@@ -1150,6 +1159,8 @@ class FabAirflowSecurityManagerOverride(AirflowSecurityManagerV2):
|
|
|
1150
1159
|
perm = self.create_permission("menu_access", resource_name)
|
|
1151
1160
|
if self.auth_role_admin not in self.builtin_roles:
|
|
1152
1161
|
role_admin = self.find_role(self.auth_role_admin)
|
|
1162
|
+
if not role_admin:
|
|
1163
|
+
role_admin = self.add_role(self.auth_role_admin)
|
|
1153
1164
|
self.add_permission_to_role(role_admin, perm)
|
|
1154
1165
|
|
|
1155
1166
|
def sync_roles(self) -> None:
|
|
@@ -1204,6 +1215,8 @@ class FabAirflowSecurityManagerOverride(AirflowSecurityManagerV2):
|
|
|
1204
1215
|
perms = [p for p in perms if p.action and p.resource]
|
|
1205
1216
|
|
|
1206
1217
|
admin = self.find_role("Admin")
|
|
1218
|
+
if not admin:
|
|
1219
|
+
admin = self.add_role("Admin")
|
|
1207
1220
|
admin.permissions = list(set(admin.permissions) | set(perms))
|
|
1208
1221
|
|
|
1209
1222
|
self.session.commit()
|
|
@@ -1231,7 +1244,7 @@ class FabAirflowSecurityManagerOverride(AirflowSecurityManagerV2):
|
|
|
1231
1244
|
if deleted_count:
|
|
1232
1245
|
self.log.info("Deleted %s faulty permissions", deleted_count)
|
|
1233
1246
|
|
|
1234
|
-
def perms_include_action(self, perms, action_name):
|
|
1247
|
+
def perms_include_action(self, perms, action_name) -> bool:
|
|
1235
1248
|
return any(perm.action and perm.action.name == action_name for perm in perms)
|
|
1236
1249
|
|
|
1237
1250
|
def bulk_sync_roles(self, roles: Iterable[dict[str, Any]]) -> None:
|
|
@@ -1288,9 +1301,10 @@ class FabAirflowSecurityManagerOverride(AirflowSecurityManagerV2):
|
|
|
1288
1301
|
except Exception as e:
|
|
1289
1302
|
log.error(const.LOGMSG_ERR_SEC_ADD_ROLE, e)
|
|
1290
1303
|
self.session.rollback()
|
|
1304
|
+
raise FabException(const.LOGMSG_ERR_SEC_ADD_ROLE) from e
|
|
1291
1305
|
return role
|
|
1292
1306
|
|
|
1293
|
-
def find_role(self, name):
|
|
1307
|
+
def find_role(self, name) -> Role | None:
|
|
1294
1308
|
"""
|
|
1295
1309
|
Find a role in the database.
|
|
1296
1310
|
|
|
@@ -1298,7 +1312,7 @@ class FabAirflowSecurityManagerOverride(AirflowSecurityManagerV2):
|
|
|
1298
1312
|
"""
|
|
1299
1313
|
return self.session.scalars(select(self.role_model).filter_by(name=name)).unique().one_or_none()
|
|
1300
1314
|
|
|
1301
|
-
def get_all_roles(self):
|
|
1315
|
+
def get_all_roles(self) -> list[Role]:
|
|
1302
1316
|
return self.session.scalars(select(self.role_model)).unique().all()
|
|
1303
1317
|
|
|
1304
1318
|
def delete_role(self, role_name: str) -> None:
|
|
@@ -1313,7 +1327,7 @@ class FabAirflowSecurityManagerOverride(AirflowSecurityManagerV2):
|
|
|
1313
1327
|
self.session.execute(delete(Role).where(Role.name == role_name))
|
|
1314
1328
|
self.session.commit()
|
|
1315
1329
|
else:
|
|
1316
|
-
raise
|
|
1330
|
+
raise FabException(f"Role named '{role_name}' does not exist")
|
|
1317
1331
|
|
|
1318
1332
|
def get_roles_from_keys(self, role_keys: list[str]) -> set[Role]:
|
|
1319
1333
|
"""
|
|
@@ -1340,7 +1354,7 @@ class FabAirflowSecurityManagerOverride(AirflowSecurityManagerV2):
|
|
|
1340
1354
|
)
|
|
1341
1355
|
return _roles
|
|
1342
1356
|
|
|
1343
|
-
def get_public_role(self):
|
|
1357
|
+
def get_public_role(self) -> Role | None:
|
|
1344
1358
|
return (
|
|
1345
1359
|
self.session.scalars(select(self.role_model).filter_by(name=self.auth_role_public))
|
|
1346
1360
|
.unique()
|
|
@@ -1363,7 +1377,7 @@ class FabAirflowSecurityManagerOverride(AirflowSecurityManagerV2):
|
|
|
1363
1377
|
password: str = "",
|
|
1364
1378
|
hashed_password: str = "",
|
|
1365
1379
|
groups: list[Group] | None = None,
|
|
1366
|
-
):
|
|
1380
|
+
) -> User:
|
|
1367
1381
|
"""Create a user."""
|
|
1368
1382
|
roles: list[Role] = []
|
|
1369
1383
|
if role:
|
|
@@ -1390,22 +1404,24 @@ class FabAirflowSecurityManagerOverride(AirflowSecurityManagerV2):
|
|
|
1390
1404
|
except Exception as e:
|
|
1391
1405
|
log.error(const.LOGMSG_ERR_SEC_ADD_USER, e)
|
|
1392
1406
|
self.session.rollback()
|
|
1393
|
-
|
|
1407
|
+
raise FabException(const.LOGMSG_ERR_SEC_ADD_USER) from e
|
|
1394
1408
|
|
|
1395
|
-
def load_user(self, pk: int) ->
|
|
1409
|
+
def load_user(self, pk: int) -> User | None:
|
|
1396
1410
|
user = self.get_user_by_id(int(pk))
|
|
1397
1411
|
if user and user.is_active:
|
|
1398
1412
|
return user
|
|
1399
1413
|
return None
|
|
1400
1414
|
|
|
1401
|
-
def get_user_by_id(self, pk):
|
|
1415
|
+
def get_user_by_id(self, pk) -> User | None:
|
|
1402
1416
|
return self.session.get(self.user_model, pk)
|
|
1403
1417
|
|
|
1404
|
-
def count_users(self):
|
|
1418
|
+
def count_users(self) -> int:
|
|
1405
1419
|
"""Return the number of users in the database."""
|
|
1406
1420
|
return self.session.scalar(select(func.count(self.user_model.id)))
|
|
1407
1421
|
|
|
1408
|
-
def add_register_user(
|
|
1422
|
+
def add_register_user(
|
|
1423
|
+
self, username, first_name, last_name, email, password="", hashed_password=""
|
|
1424
|
+
) -> RegisterUser | None:
|
|
1409
1425
|
"""
|
|
1410
1426
|
Add a registration request for the user.
|
|
1411
1427
|
|
|
@@ -1430,16 +1446,10 @@ class FabAirflowSecurityManagerOverride(AirflowSecurityManagerV2):
|
|
|
1430
1446
|
self.session.rollback()
|
|
1431
1447
|
return None
|
|
1432
1448
|
|
|
1433
|
-
def find_user(self, username=None, email=None):
|
|
1449
|
+
def find_user(self, username=None, email=None) -> User | None:
|
|
1434
1450
|
"""Find user by username or email."""
|
|
1435
1451
|
if username:
|
|
1436
1452
|
try:
|
|
1437
|
-
if self.auth_username_ci:
|
|
1438
|
-
return self.session.scalars(
|
|
1439
|
-
select(self.user_model).where(
|
|
1440
|
-
func.lower(self.user_model.username) == func.lower(username)
|
|
1441
|
-
)
|
|
1442
|
-
).one_or_none()
|
|
1443
1453
|
return self.session.scalars(
|
|
1444
1454
|
select(self.user_model).where(
|
|
1445
1455
|
func.lower(self.user_model.username) == func.lower(username)
|
|
@@ -1447,13 +1457,12 @@ class FabAirflowSecurityManagerOverride(AirflowSecurityManagerV2):
|
|
|
1447
1457
|
).one_or_none()
|
|
1448
1458
|
except MultipleResultsFound:
|
|
1449
1459
|
log.error("Multiple results found for user %s", username)
|
|
1450
|
-
return None
|
|
1451
1460
|
elif email:
|
|
1452
1461
|
try:
|
|
1453
1462
|
return self.session.scalars(select(self.user_model).filter_by(email=email)).one_or_none()
|
|
1454
1463
|
except MultipleResultsFound:
|
|
1455
1464
|
log.error("Multiple results found for user with email %s", email)
|
|
1456
|
-
|
|
1465
|
+
return None
|
|
1457
1466
|
|
|
1458
1467
|
def update_user(self, user: User) -> bool:
|
|
1459
1468
|
try:
|
|
@@ -1466,7 +1475,7 @@ class FabAirflowSecurityManagerOverride(AirflowSecurityManagerV2):
|
|
|
1466
1475
|
return False
|
|
1467
1476
|
return True
|
|
1468
1477
|
|
|
1469
|
-
def del_register_user(self, register_user):
|
|
1478
|
+
def del_register_user(self, register_user) -> bool:
|
|
1470
1479
|
"""
|
|
1471
1480
|
Delete registration object from database.
|
|
1472
1481
|
|
|
@@ -1481,10 +1490,10 @@ class FabAirflowSecurityManagerOverride(AirflowSecurityManagerV2):
|
|
|
1481
1490
|
self.session.rollback()
|
|
1482
1491
|
return False
|
|
1483
1492
|
|
|
1484
|
-
def get_all_users(self):
|
|
1493
|
+
def get_all_users(self) -> list[User]:
|
|
1485
1494
|
return self.session.scalars(select(self.user_model)).all()
|
|
1486
1495
|
|
|
1487
|
-
def update_user_auth_stat(self, user, success=True):
|
|
1496
|
+
def update_user_auth_stat(self, user, success=True) -> None:
|
|
1488
1497
|
"""
|
|
1489
1498
|
Update user authentication stats.
|
|
1490
1499
|
|
|
@@ -1524,7 +1533,7 @@ class FabAirflowSecurityManagerOverride(AirflowSecurityManagerV2):
|
|
|
1524
1533
|
"""
|
|
1525
1534
|
return self.session.scalars(select(self.action_model).filter_by(name=name)).one_or_none()
|
|
1526
1535
|
|
|
1527
|
-
def create_action(self, name):
|
|
1536
|
+
def create_action(self, name) -> Action:
|
|
1528
1537
|
"""
|
|
1529
1538
|
Add an action to the backend, model action.
|
|
1530
1539
|
|
|
@@ -1583,7 +1592,7 @@ class FabAirflowSecurityManagerOverride(AirflowSecurityManagerV2):
|
|
|
1583
1592
|
"""
|
|
1584
1593
|
return self.session.scalars(select(self.resource_model).filter_by(name=name)).one_or_none()
|
|
1585
1594
|
|
|
1586
|
-
def create_resource(self, name) -> Resource
|
|
1595
|
+
def create_resource(self, name) -> Resource:
|
|
1587
1596
|
"""
|
|
1588
1597
|
Create a resource with the given name.
|
|
1589
1598
|
|
|
@@ -1600,6 +1609,7 @@ class FabAirflowSecurityManagerOverride(AirflowSecurityManagerV2):
|
|
|
1600
1609
|
except Exception as e:
|
|
1601
1610
|
log.error(const.LOGMSG_ERR_SEC_ADD_VIEWMENU, e)
|
|
1602
1611
|
self.session.rollback()
|
|
1612
|
+
raise FabException(const.LOGMSG_ERR_SEC_ADD_VIEWMENU) from e
|
|
1603
1613
|
return resource
|
|
1604
1614
|
|
|
1605
1615
|
"""
|
|
@@ -1738,7 +1748,7 @@ class FabAirflowSecurityManagerOverride(AirflowSecurityManagerV2):
|
|
|
1738
1748
|
self.session.rollback()
|
|
1739
1749
|
|
|
1740
1750
|
@staticmethod
|
|
1741
|
-
def get_user_roles(user=None):
|
|
1751
|
+
def get_user_roles(user=None) -> list[Role]:
|
|
1742
1752
|
"""
|
|
1743
1753
|
Get all the roles associated with the user.
|
|
1744
1754
|
|
|
@@ -1755,7 +1765,7 @@ class FabAirflowSecurityManagerOverride(AirflowSecurityManagerV2):
|
|
|
1755
1765
|
--------------------
|
|
1756
1766
|
"""
|
|
1757
1767
|
|
|
1758
|
-
def auth_user_ldap(self, username, password, rotate_session_id=True):
|
|
1768
|
+
def auth_user_ldap(self, username, password, rotate_session_id=True) -> User | None:
|
|
1759
1769
|
"""
|
|
1760
1770
|
Authenticate user with LDAP.
|
|
1761
1771
|
|
|
@@ -1948,11 +1958,11 @@ class FabAirflowSecurityManagerOverride(AirflowSecurityManagerV2):
|
|
|
1948
1958
|
user = self.find_user(username=username)
|
|
1949
1959
|
if user is None:
|
|
1950
1960
|
user = self.find_user(email=username)
|
|
1951
|
-
if user is None:
|
|
1961
|
+
if user is None or user.password is None:
|
|
1952
1962
|
return False
|
|
1953
1963
|
return check_password_hash(user.password, password)
|
|
1954
1964
|
|
|
1955
|
-
def auth_user_db(self, username, password, rotate_session_id=True):
|
|
1965
|
+
def auth_user_db(self, username, password, rotate_session_id=True) -> User | None:
|
|
1956
1966
|
"""
|
|
1957
1967
|
Authenticate user, auth db style.
|
|
1958
1968
|
|
|
@@ -1976,6 +1986,8 @@ class FabAirflowSecurityManagerOverride(AirflowSecurityManagerV2):
|
|
|
1976
1986
|
)
|
|
1977
1987
|
log.info(LOGMSG_WAR_SEC_LOGIN_FAILED, username)
|
|
1978
1988
|
return None
|
|
1989
|
+
if user.password is None:
|
|
1990
|
+
return None
|
|
1979
1991
|
if check_password_hash(user.password, password):
|
|
1980
1992
|
if rotate_session_id:
|
|
1981
1993
|
self._rotate_session_id()
|
|
@@ -1985,7 +1997,7 @@ class FabAirflowSecurityManagerOverride(AirflowSecurityManagerV2):
|
|
|
1985
1997
|
log.info(LOGMSG_WAR_SEC_LOGIN_FAILED, username)
|
|
1986
1998
|
return None
|
|
1987
1999
|
|
|
1988
|
-
def set_oauth_session(self, provider, oauth_response):
|
|
2000
|
+
def set_oauth_session(self, provider, oauth_response) -> None:
|
|
1989
2001
|
"""Set the current session with OAuth user secrets."""
|
|
1990
2002
|
# Get this provider key names for token_key and token_secret
|
|
1991
2003
|
token_key = self.get_oauth_token_key_name(provider)
|
|
@@ -2019,7 +2031,7 @@ class FabAirflowSecurityManagerOverride(AirflowSecurityManagerV2):
|
|
|
2019
2031
|
if _provider["name"] == provider:
|
|
2020
2032
|
return _provider.get("token_secret", "oauth_token_secret")
|
|
2021
2033
|
|
|
2022
|
-
def auth_user_oauth(self, userinfo):
|
|
2034
|
+
def auth_user_oauth(self, userinfo, rotate_session_id=True) -> User | None:
|
|
2023
2035
|
"""
|
|
2024
2036
|
Authenticate user with OAuth.
|
|
2025
2037
|
|
|
@@ -2073,7 +2085,8 @@ class FabAirflowSecurityManagerOverride(AirflowSecurityManagerV2):
|
|
|
2073
2085
|
|
|
2074
2086
|
# LOGIN SUCCESS (only if user is now registered)
|
|
2075
2087
|
if user:
|
|
2076
|
-
|
|
2088
|
+
if rotate_session_id:
|
|
2089
|
+
self._rotate_session_id()
|
|
2077
2090
|
self.update_user_auth_stat(user)
|
|
2078
2091
|
return user
|
|
2079
2092
|
return None
|
|
@@ -2217,7 +2230,7 @@ class FabAirflowSecurityManagerOverride(AirflowSecurityManagerV2):
|
|
|
2217
2230
|
# decode - if empty string, default to fallback, otherwise take first element
|
|
2218
2231
|
return raw_value[0].decode("utf-8") or fallback
|
|
2219
2232
|
|
|
2220
|
-
def auth_user_remote_user(self, username):
|
|
2233
|
+
def auth_user_remote_user(self, username) -> User | None:
|
|
2221
2234
|
"""
|
|
2222
2235
|
REMOTE_USER user Authentication.
|
|
2223
2236
|
|
|
@@ -2253,7 +2266,7 @@ class FabAirflowSecurityManagerOverride(AirflowSecurityManagerV2):
|
|
|
2253
2266
|
---------------
|
|
2254
2267
|
"""
|
|
2255
2268
|
|
|
2256
|
-
def _rotate_session_id(self):
|
|
2269
|
+
def _rotate_session_id(self) -> None:
|
|
2257
2270
|
"""
|
|
2258
2271
|
Rotate the session ID.
|
|
2259
2272
|
|
|
@@ -2261,7 +2274,7 @@ class FabAirflowSecurityManagerOverride(AirflowSecurityManagerV2):
|
|
|
2261
2274
|
database session backend.
|
|
2262
2275
|
"""
|
|
2263
2276
|
if conf.get("fab", "SESSION_BACKEND") == "database":
|
|
2264
|
-
session.sid = str(uuid.uuid4())
|
|
2277
|
+
session.sid = str(uuid.uuid4()) # type: ignore
|
|
2265
2278
|
|
|
2266
2279
|
def _get_microsoft_jwks(self) -> list[dict[str, Any]]:
|
|
2267
2280
|
import requests
|
|
@@ -2273,7 +2286,7 @@ class FabAirflowSecurityManagerOverride(AirflowSecurityManagerV2):
|
|
|
2273
2286
|
if verify_signature:
|
|
2274
2287
|
from authlib.jose import JsonWebKey, jwt as authlib_jwt
|
|
2275
2288
|
|
|
2276
|
-
keyset = JsonWebKey.import_key_set(self._get_microsoft_jwks())
|
|
2289
|
+
keyset = JsonWebKey.import_key_set(self._get_microsoft_jwks()) # type: ignore
|
|
2277
2290
|
claims = authlib_jwt.decode(id_token, keyset)
|
|
2278
2291
|
claims.validate()
|
|
2279
2292
|
return claims
|
|
@@ -2415,7 +2428,7 @@ class FabAirflowSecurityManagerOverride(AirflowSecurityManagerV2):
|
|
|
2415
2428
|
except ldap.INVALID_CREDENTIALS:
|
|
2416
2429
|
return False
|
|
2417
2430
|
|
|
2418
|
-
def _ldap_calculate_user_roles(self, user_attributes: dict[str, list[bytes]]) -> list[
|
|
2431
|
+
def _ldap_calculate_user_roles(self, user_attributes: dict[str, list[bytes]]) -> list[Role]:
|
|
2419
2432
|
user_role_objects = set()
|
|
2420
2433
|
|
|
2421
2434
|
# apply AUTH_ROLES_MAPPING
|
|
@@ -2495,7 +2508,7 @@ class FabAirflowSecurityManagerOverride(AirflowSecurityManagerV2):
|
|
|
2495
2508
|
else:
|
|
2496
2509
|
getattr(log, level)(text.replace("<br>", "\n").replace("<b>", "*").replace("</b>", "*"))
|
|
2497
2510
|
|
|
2498
|
-
def _oauth_calculate_user_roles(self, userinfo) -> list[
|
|
2511
|
+
def _oauth_calculate_user_roles(self, userinfo) -> list[Role]:
|
|
2499
2512
|
user_role_objects = set()
|
|
2500
2513
|
|
|
2501
2514
|
# apply AUTH_ROLES_MAPPING
|
|
@@ -26,6 +26,7 @@ from flask_appbuilder.security.views import (
|
|
|
26
26
|
UserOAuthModelView,
|
|
27
27
|
UserRemoteUserModelView,
|
|
28
28
|
)
|
|
29
|
+
from wtforms.validators import DataRequired
|
|
29
30
|
|
|
30
31
|
from airflow.providers.fab.www.security import permissions
|
|
31
32
|
|
|
@@ -186,6 +187,17 @@ class CustomUserDBModelView(MultiResourceUserMixin, UserDBModelView):
|
|
|
186
187
|
"conf_password",
|
|
187
188
|
]
|
|
188
189
|
|
|
190
|
+
edit_columns = [
|
|
191
|
+
"first_name",
|
|
192
|
+
"last_name",
|
|
193
|
+
"username",
|
|
194
|
+
"active",
|
|
195
|
+
"email",
|
|
196
|
+
"roles",
|
|
197
|
+
]
|
|
198
|
+
|
|
199
|
+
validators_columns = {"roles": [DataRequired()]}
|
|
200
|
+
|
|
189
201
|
base_permissions = [
|
|
190
202
|
permissions.ACTION_CAN_CREATE,
|
|
191
203
|
permissions.ACTION_CAN_READ,
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
#
|
|
2
|
+
# Licensed to the Apache Software Foundation (ASF) under one
|
|
3
|
+
# or more contributor license agreements. See the NOTICE file
|
|
4
|
+
# distributed with this work for additional information
|
|
5
|
+
# regarding copyright ownership. The ASF licenses this file
|
|
6
|
+
# to you under the Apache License, Version 2.0 (the
|
|
7
|
+
# "License"); you may not use this file except in compliance
|
|
8
|
+
# with the License. You may obtain a copy of the License at
|
|
9
|
+
#
|
|
10
|
+
# http://www.apache.org/licenses/LICENSE-2.0
|
|
11
|
+
#
|
|
12
|
+
# Unless required by applicable law or agreed to in writing,
|
|
13
|
+
# software distributed under the License is distributed on an
|
|
14
|
+
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
|
15
|
+
# KIND, either express or implied. See the License for the
|
|
16
|
+
# specific language governing permissions and limitations
|
|
17
|
+
# under the License.
|
|
18
|
+
from __future__ import annotations
|