From 4c28728482480143b4de3cc05a9b13bb7a4f4276 Mon Sep 17 00:00:00 2001 From: Ninad Kale Date: Sun, 1 Mar 2026 15:34:12 +0530 Subject: [PATCH 1/7] Replace connexion with FastAPI for FAB provider Remove all connexion infrastructure from FAB provider after migration to FastAPI completed in prior PRs. - Deleted old connexion endpoints (api_endpoints/) - Deleted marshmallow schemas (schemas/) - Deleted OpenAPI YAML spec - Deleted connexion utilities (www/api_connexion/) - Removed connexion dependency from pyproject.toml - Removed connexion initialization functions - Relocated test utilities from deleted api_connexion_utils.py to test_utils.py - Updated 9 test files to import from new location - Cleaned up test decorators and conftest files Co-Authored-By: Claude Sonnet 4.5 --- providers/fab/.pre-commit-config.yaml | 1 - providers/fab/README.rst | 1 - providers/fab/docs/index.rst | 7 +- providers/fab/pyproject.toml | 1 - .../auth_manager/api_endpoints/__init__.py | 16 - .../role_and_permission_endpoint.py | 174 ---- .../api_endpoints/user_endpoint.py | 220 ----- .../fab/auth_manager/fab_auth_manager.py | 27 +- .../fab/auth_manager/openapi/__init__.py | 16 - .../auth_manager/openapi/v1-flask-api.yaml | 709 --------------- .../fab/auth_manager/schemas/__init__.py | 16 - .../schemas/role_and_permission_schema.py | 103 --- .../fab/auth_manager/schemas/user_schema.py | 73 -- .../fab/www/api_connexion/__init__.py | 17 - .../fab/www/api_connexion/exceptions.py | 197 ----- .../fab/www/api_connexion/parameters.py | 86 -- .../fab/www/api_connexion/security.py | 85 -- .../providers/fab/www/api_connexion/types.py | 28 - .../fab/src/airflow/providers/fab/www/app.py | 4 - .../airflow/providers/fab/www/constants.py | 7 - .../fab/www/extensions/init_views.py | 98 +-- .../auth_manager/api_endpoints/__init__.py | 16 - .../remote_user_api_auth_backend.py | 84 -- .../auth_manager/api_endpoints/test_auth.py | 77 -- .../test_role_and_permission_endpoint.py | 572 ------------ .../api_endpoints/test_user_endpoint.py | 829 ------------------ .../tests/unit/fab/auth_manager/conftest.py | 7 - .../unit/fab/auth_manager/schemas/__init__.py | 17 - .../test_role_and_permission_schema.py | 105 --- .../auth_manager/schemas/test_user_schema.py | 152 ---- .../fab/auth_manager/test_fab_auth_manager.py | 2 +- .../unit/fab/auth_manager/test_security.py | 2 +- .../api_connexion_utils.py => test_utils.py} | 23 - .../auth_manager/views/test_permissions.py | 2 +- .../fab/auth_manager/views/test_roles_list.py | 2 +- .../unit/fab/auth_manager/views/test_user.py | 2 +- .../fab/auth_manager/views/test_user_edit.py | 2 +- .../fab/auth_manager/views/test_user_stats.py | 2 +- providers/fab/tests/unit/fab/decorators.py | 2 - .../fab/tests/unit/fab/www/views/conftest.py | 5 +- .../www/views/test_views_custom_user_views.py | 2 +- 41 files changed, 14 insertions(+), 3777 deletions(-) delete mode 100644 providers/fab/src/airflow/providers/fab/auth_manager/api_endpoints/__init__.py delete mode 100644 providers/fab/src/airflow/providers/fab/auth_manager/api_endpoints/role_and_permission_endpoint.py delete mode 100644 providers/fab/src/airflow/providers/fab/auth_manager/api_endpoints/user_endpoint.py delete mode 100644 providers/fab/src/airflow/providers/fab/auth_manager/openapi/__init__.py delete mode 100644 providers/fab/src/airflow/providers/fab/auth_manager/openapi/v1-flask-api.yaml delete mode 100644 providers/fab/src/airflow/providers/fab/auth_manager/schemas/__init__.py delete mode 100644 providers/fab/src/airflow/providers/fab/auth_manager/schemas/role_and_permission_schema.py delete mode 100644 providers/fab/src/airflow/providers/fab/auth_manager/schemas/user_schema.py delete mode 100644 providers/fab/src/airflow/providers/fab/www/api_connexion/__init__.py delete mode 100644 providers/fab/src/airflow/providers/fab/www/api_connexion/exceptions.py delete mode 100644 providers/fab/src/airflow/providers/fab/www/api_connexion/parameters.py delete mode 100644 providers/fab/src/airflow/providers/fab/www/api_connexion/security.py delete mode 100644 providers/fab/src/airflow/providers/fab/www/api_connexion/types.py delete mode 100644 providers/fab/tests/unit/fab/auth_manager/api_endpoints/__init__.py delete mode 100644 providers/fab/tests/unit/fab/auth_manager/api_endpoints/remote_user_api_auth_backend.py delete mode 100644 providers/fab/tests/unit/fab/auth_manager/api_endpoints/test_auth.py delete mode 100644 providers/fab/tests/unit/fab/auth_manager/api_endpoints/test_role_and_permission_endpoint.py delete mode 100644 providers/fab/tests/unit/fab/auth_manager/api_endpoints/test_user_endpoint.py delete mode 100644 providers/fab/tests/unit/fab/auth_manager/schemas/__init__.py delete mode 100644 providers/fab/tests/unit/fab/auth_manager/schemas/test_role_and_permission_schema.py delete mode 100644 providers/fab/tests/unit/fab/auth_manager/schemas/test_user_schema.py rename providers/fab/tests/unit/fab/auth_manager/{api_endpoints/api_connexion_utils.py => test_utils.py} (79%) diff --git a/providers/fab/.pre-commit-config.yaml b/providers/fab/.pre-commit-config.yaml index dcb4be2cbed6e..dba42311bf790 100644 --- a/providers/fab/.pre-commit-config.yaml +++ b/providers/fab/.pre-commit-config.yaml @@ -31,7 +31,6 @@ repos: files: ^src/airflow/providers/fab/www/ exclude: | (?x) - ^src/airflow/providers/fab/www/api_connexion/.*| ^src/airflow/providers/fab/www/extensions/.*| ^src/airflow/providers/fab/www/security/.* entry: ../../scripts/ci/prek/compile_provider_assets.py fab diff --git a/providers/fab/README.rst b/providers/fab/README.rst index 8edef706f05e9..6ad71e1309541 100644 --- a/providers/fab/README.rst +++ b/providers/fab/README.rst @@ -63,7 +63,6 @@ PIP package Version required ``msgpack`` ``>=1.0.0; python_version < "3.13"`` ``flask-sqlalchemy`` ``>=3.0.5; python_version < "3.13"`` ``flask-wtf`` ``>=1.1.0; python_version < "3.13"`` -``connexion[flask]`` ``>=2.14.2,<3.0; python_version < "3.13"`` ``jmespath`` ``>=0.7.0; python_version < "3.13"`` ``werkzeug`` ``>=2.2,<4; python_version < "3.13"`` ``wtforms`` ``>=3.0,<4; python_version < "3.13"`` diff --git a/providers/fab/docs/index.rst b/providers/fab/docs/index.rst index 2acfa4ed72648..2f461706c5395 100644 --- a/providers/fab/docs/index.rst +++ b/providers/fab/docs/index.rst @@ -104,9 +104,9 @@ Requirements The minimum Apache Airflow version supported by this provider distribution is ``3.0.2``. -========================================== ========================================== +========================================== ========================================= PIP package Version required -========================================== ========================================== +========================================== ========================================= ``apache-airflow`` ``>=3.0.2`` ``apache-airflow-providers-common-compat`` ``>=1.12.0`` ``blinker`` ``>=1.6.2; python_version < "3.13"`` @@ -117,13 +117,12 @@ PIP package Version required ``msgpack`` ``>=1.0.0; python_version < "3.13"`` ``flask-sqlalchemy`` ``>=3.0.5; python_version < "3.13"`` ``flask-wtf`` ``>=1.1.0; python_version < "3.13"`` -``connexion[flask]`` ``>=2.14.2,<3.0; python_version < "3.13"`` ``jmespath`` ``>=0.7.0; python_version < "3.13"`` ``werkzeug`` ``>=2.2,<4; python_version < "3.13"`` ``wtforms`` ``>=3.0,<4; python_version < "3.13"`` ``cachetools`` ``>=6.0; python_version < "3.13"`` ``flask_limiter`` ``>3,!=3.13,<4`` -========================================== ========================================== +========================================== ========================================= Cross provider package dependencies ----------------------------------- diff --git a/providers/fab/pyproject.toml b/providers/fab/pyproject.toml index 0efba6edbd788..978dc8debe480 100644 --- a/providers/fab/pyproject.toml +++ b/providers/fab/pyproject.toml @@ -84,7 +84,6 @@ dependencies = [ "msgpack>=1.0.0; python_version < '3.13'", "flask-sqlalchemy>=3.0.5; python_version < '3.13'", "flask-wtf>=1.1.0; python_version < '3.13'", - "connexion[flask]>=2.14.2,<3.0; python_version < '3.13'", "jmespath>=0.7.0; python_version < '3.13'", "werkzeug>=2.2,<4; python_version < '3.13'", "wtforms>=3.0,<4; python_version < '3.13'", diff --git a/providers/fab/src/airflow/providers/fab/auth_manager/api_endpoints/__init__.py b/providers/fab/src/airflow/providers/fab/auth_manager/api_endpoints/__init__.py deleted file mode 100644 index 13a83393a9124..0000000000000 --- a/providers/fab/src/airflow/providers/fab/auth_manager/api_endpoints/__init__.py +++ /dev/null @@ -1,16 +0,0 @@ -# Licensed to the Apache Software Foundation (ASF) under one -# or more contributor license agreements. See the NOTICE file -# distributed with this work for additional information -# regarding copyright ownership. The ASF licenses this file -# to you under the Apache License, Version 2.0 (the -# "License"); you may not use this file except in compliance -# with the License. You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, -# software distributed under the License is distributed on an -# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY -# KIND, either express or implied. See the License for the -# specific language governing permissions and limitations -# under the License. diff --git a/providers/fab/src/airflow/providers/fab/auth_manager/api_endpoints/role_and_permission_endpoint.py b/providers/fab/src/airflow/providers/fab/auth_manager/api_endpoints/role_and_permission_endpoint.py deleted file mode 100644 index 56e0a7fd38f29..0000000000000 --- a/providers/fab/src/airflow/providers/fab/auth_manager/api_endpoints/role_and_permission_endpoint.py +++ /dev/null @@ -1,174 +0,0 @@ -# Licensed to the Apache Software Foundation (ASF) under one -# or more contributor license agreements. See the NOTICE file -# distributed with this work for additional information -# regarding copyright ownership. The ASF licenses this file -# to you under the Apache License, Version 2.0 (the -# "License"); you may not use this file except in compliance -# with the License. You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, -# software distributed under the License is distributed on an -# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY -# KIND, either express or implied. See the License for the -# specific language governing permissions and limitations -# under the License. -from __future__ import annotations - -from http import HTTPStatus -from typing import TYPE_CHECKING, cast - -from connexion import NoContent -from flask import request -from marshmallow import ValidationError -from sqlalchemy import asc, desc, func, select - -from airflow.api_fastapi.app import get_auth_manager -from airflow.providers.fab.auth_manager.models import Action, Role -from airflow.providers.fab.auth_manager.schemas.role_and_permission_schema import ( - ActionCollection, - RoleCollection, - action_collection_schema, - role_collection_schema, - role_schema, -) -from airflow.providers.fab.www.api_connexion.exceptions import AlreadyExists, BadRequest, NotFound -from airflow.providers.fab.www.api_connexion.parameters import check_limit, format_parameters -from airflow.providers.fab.www.api_connexion.security import requires_access_custom_view -from airflow.providers.fab.www.security import permissions - -if TYPE_CHECKING: - from airflow.providers.fab.auth_manager.fab_auth_manager import FabAuthManager - from airflow.providers.fab.auth_manager.security_manager.override import FabAirflowSecurityManagerOverride - from airflow.providers.fab.www.api_connexion.types import APIResponse, UpdateMask - - -def _check_action_and_resource(sm: FabAirflowSecurityManagerOverride, perms: list[tuple[str, str]]) -> None: - """ - Check if the action or resource exists and otherwise raise 400. - - This function is intended for use in the REST API because it raises an HTTP error 400 - """ - for action, resource in perms: - if not sm.get_action(action): - raise BadRequest(detail=f"The specified action: {action!r} was not found") - if not sm.get_resource(resource): - raise BadRequest(detail=f"The specified resource: {resource!r} was not found") - - -@requires_access_custom_view("GET", permissions.RESOURCE_ROLE) -def get_role(*, role_name: str) -> APIResponse: - """Get role.""" - security_manager = cast("FabAuthManager", get_auth_manager()).security_manager - role = security_manager.find_role(name=role_name) - if not role: - raise NotFound(title="Role not found", detail=f"Role with name {role_name!r} was not found") - return role_schema.dump(role) - - -@requires_access_custom_view("GET", permissions.RESOURCE_ROLE) -@format_parameters({"limit": check_limit}) -def get_roles(*, order_by: str = "name", limit: int, offset: int | None = None) -> APIResponse: - """Get roles.""" - security_manager = cast("FabAuthManager", get_auth_manager()).security_manager - session = security_manager.session - total_entries = session.scalars(select(func.count(Role.id))).one() - direction = desc if order_by.startswith("-") else asc - to_replace = {"role_id": "id"} - order_param = order_by.strip("-") - order_param = to_replace.get(order_param, order_param) - allowed_sort_attrs = ["role_id", "name"] - if order_by not in allowed_sort_attrs: - raise BadRequest( - detail=f"Ordering with '{order_by}' is disallowed or the attribute does not exist on the model" - ) - - query = select(Role) - roles = ( - session.scalars(query.order_by(direction(getattr(Role, order_param))).offset(offset).limit(limit)) - .unique() - .all() - ) - - return role_collection_schema.dump(RoleCollection(roles=roles, total_entries=total_entries)) - - -@requires_access_custom_view("GET", permissions.RESOURCE_ACTION) -@format_parameters({"limit": check_limit}) -def get_permissions(*, limit: int, offset: int | None = None) -> APIResponse: - """Get permissions.""" - security_manager = cast("FabAuthManager", get_auth_manager()).security_manager - session = security_manager.session - total_entries = session.scalars(select(func.count(Action.id))).one() - query = select(Action) - actions = session.scalars(query.offset(offset).limit(limit)).all() - return action_collection_schema.dump(ActionCollection(actions=actions, total_entries=total_entries)) - - -@requires_access_custom_view("DELETE", permissions.RESOURCE_ROLE) -def delete_role(*, role_name: str) -> APIResponse: - """Delete a role.""" - security_manager = cast("FabAuthManager", get_auth_manager()).security_manager - - role = security_manager.find_role(name=role_name) - if not role: - raise NotFound(title="Role not found", detail=f"Role with name {role_name!r} was not found") - security_manager.delete_role(role_name=role_name) - return NoContent, HTTPStatus.NO_CONTENT - - -@requires_access_custom_view("PUT", permissions.RESOURCE_ROLE) -def patch_role(*, role_name: str, update_mask: UpdateMask = None) -> APIResponse: - """Update a role.""" - security_manager = cast("FabAuthManager", get_auth_manager()).security_manager - body = request.json - if body is None: - raise BadRequest("Request body is required") - try: - data = role_schema.load(body) - except ValidationError as err: - raise BadRequest(detail=str(err.messages)) - role = security_manager.find_role(name=role_name) - if not role: - raise NotFound(title="Role not found", detail=f"Role with name {role_name!r} was not found") - if update_mask: - update_mask = [i.strip() for i in update_mask] - data_ = {} - for field in update_mask: - if field in data and field != "permissions": - data_[field] = data[field] - elif field == "actions": - data_["permissions"] = data["permissions"] - else: - raise BadRequest(detail=f"'{field}' in update_mask is unknown") - data = data_ - if "permissions" in data: - perms = [(item["action"]["name"], item["resource"]["name"]) for item in data["permissions"] if item] - _check_action_and_resource(security_manager, perms) - security_manager.bulk_sync_roles([{"role": role_name, "perms": perms}]) - new_name = data.get("name") - if new_name is not None and new_name != role.name: - security_manager.update_role(role_id=role.id, name=new_name) - return role_schema.dump(role) - - -@requires_access_custom_view("POST", permissions.RESOURCE_ROLE) -def post_role() -> APIResponse: - """Create a new role.""" - security_manager = cast("FabAuthManager", get_auth_manager()).security_manager - body = request.json - if body is None: - raise BadRequest("Request body is required") - try: - data = role_schema.load(body) - except ValidationError as err: - raise BadRequest(detail=str(err.messages)) - role = security_manager.find_role(name=data["name"]) - if not role: - perms = [(item["action"]["name"], item["resource"]["name"]) for item in data["permissions"] if item] - _check_action_and_resource(security_manager, perms) - security_manager.bulk_sync_roles([{"role": data["name"], "perms": perms}]) - return role_schema.dump(role) - detail = f"Role with name {role.name!r} already exists; please update with the PATCH endpoint" - raise AlreadyExists(detail=detail) diff --git a/providers/fab/src/airflow/providers/fab/auth_manager/api_endpoints/user_endpoint.py b/providers/fab/src/airflow/providers/fab/auth_manager/api_endpoints/user_endpoint.py deleted file mode 100644 index 073752e74fb4f..0000000000000 --- a/providers/fab/src/airflow/providers/fab/auth_manager/api_endpoints/user_endpoint.py +++ /dev/null @@ -1,220 +0,0 @@ -# Licensed to the Apache Software Foundation (ASF) under one -# or more contributor license agreements. See the NOTICE file -# distributed with this work for additional information -# regarding copyright ownership. The ASF licenses this file -# to you under the Apache License, Version 2.0 (the -# "License"); you may not use this file except in compliance -# with the License. You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, -# software distributed under the License is distributed on an -# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY -# KIND, either express or implied. See the License for the -# specific language governing permissions and limitations -# under the License. -from __future__ import annotations - -from http import HTTPStatus -from typing import TYPE_CHECKING, cast - -from connexion import NoContent -from flask import request -from marshmallow import ValidationError -from sqlalchemy import asc, desc, func, select -from werkzeug.security import generate_password_hash - -from airflow.api_fastapi.app import get_auth_manager -from airflow.providers.fab.auth_manager.models import User -from airflow.providers.fab.auth_manager.schemas.user_schema import ( - UserCollection, - user_collection_item_schema, - user_collection_schema, - user_schema, -) -from airflow.providers.fab.www.api_connexion.exceptions import AlreadyExists, BadRequest, NotFound, Unknown -from airflow.providers.fab.www.api_connexion.parameters import check_limit, format_parameters -from airflow.providers.fab.www.api_connexion.security import requires_access_custom_view -from airflow.providers.fab.www.security import permissions - -if TYPE_CHECKING: - from airflow.providers.fab.auth_manager.fab_auth_manager import FabAuthManager - from airflow.providers.fab.auth_manager.models import Role - from airflow.providers.fab.www.api_connexion.types import APIResponse, UpdateMask - - -@requires_access_custom_view("GET", permissions.RESOURCE_USER) -def get_user(*, username: str) -> APIResponse: - """Get a user.""" - security_manager = cast("FabAuthManager", get_auth_manager()).security_manager - user = security_manager.find_user(username=username) - if not user: - raise NotFound(title="User not found", detail=f"The User with username `{username}` was not found") - return user_collection_item_schema.dump(user) - - -@requires_access_custom_view("GET", permissions.RESOURCE_USER) -@format_parameters({"limit": check_limit}) -def get_users(*, limit: int, order_by: str = "id", offset: int | None = None) -> APIResponse: - """Get users.""" - security_manager = cast("FabAuthManager", get_auth_manager()).security_manager - session = security_manager.session - total_entries = session.execute(select(func.count(User.id))).scalar() - direction = desc if order_by.startswith("-") else asc - to_replace = {"user_id": "id"} - order_param = order_by.strip("-") - order_param = to_replace.get(order_param, order_param) - allowed_sort_attrs = [ - "id", - "first_name", - "last_name", - "user_name", - "email", - "is_active", - "role", - ] - if order_by not in allowed_sort_attrs: - raise BadRequest( - detail=f"Ordering with '{order_by}' is disallowed or the attribute does not exist on the model" - ) - - query = select(User).order_by(direction(getattr(User, order_param))).offset(offset).limit(limit) - users = session.scalars(query).all() - - return user_collection_schema.dump(UserCollection(users=users, total_entries=total_entries)) - - -@requires_access_custom_view("POST", permissions.RESOURCE_USER) -def post_user() -> APIResponse: - """Create a new user.""" - if request.json is None: - raise BadRequest("Request body is required") - try: - data = user_schema.load(request.json) - except ValidationError as e: - raise BadRequest(detail=str(e.messages)) - - security_manager = cast("FabAuthManager", get_auth_manager()).security_manager - username = data["username"] - email = data["email"] - - if security_manager.find_user(username=username): - detail = f"Username `{username}` already exists. Use PATCH to update." - raise AlreadyExists(detail=detail) - if security_manager.find_user(email=email): - detail = f"The email `{email}` is already taken." - raise AlreadyExists(detail=detail) - - roles_to_add = [] - missing_role_names = [] - for role_data in data.pop("roles", ()): - role_name = role_data["name"] - role = security_manager.find_role(role_name) - if role is None: - missing_role_names.append(role_name) - else: - roles_to_add.append(role) - if missing_role_names: - detail = f"Unknown roles: {', '.join(repr(n) for n in missing_role_names)}" - raise BadRequest(detail=detail) - - if not roles_to_add: # No roles provided, use the F.A.B's default registered user role. - r = security_manager.find_role(security_manager.auth_user_registration_role) - if r: - roles_to_add.append(r) - - user = security_manager.add_user(role=roles_to_add, **data) - if not user: - detail = f"Failed to add user `{username}`." - raise Unknown(detail=detail) - - return user_schema.dump(user) - - -@requires_access_custom_view("PUT", permissions.RESOURCE_USER) -def patch_user(*, username: str, update_mask: UpdateMask = None) -> APIResponse: - """Update a user.""" - if request.json is None: - raise BadRequest("Request body is required") - try: - data = user_schema.load(request.json) - except ValidationError as e: - raise BadRequest(detail=str(e.messages)) - - security_manager = cast("FabAuthManager", get_auth_manager()).security_manager - - user = security_manager.find_user(username=username) - if user is None: - detail = f"The User with username `{username}` was not found" - raise NotFound(title="User not found", detail=detail) - # Check unique username - new_username = data.get("username") - if new_username and new_username != username: - if security_manager.find_user(username=new_username): - raise AlreadyExists(detail=f"The username `{new_username}` already exists") - - # Check unique email - email = data.get("email") - if email and email != user.email: - if security_manager.find_user(email=email): - raise AlreadyExists(detail=f"The email `{email}` already exists") - - # Get fields to update. - if update_mask is not None: - masked_data = {} - missing_mask_names = [] - for field_raw in update_mask: - field = field_raw.strip() - try: - masked_data[field] = data[field] - except KeyError: - missing_mask_names.append(field) - if missing_mask_names: - detail = f"Unknown update masks: {', '.join(repr(n) for n in missing_mask_names)}" - raise BadRequest(detail=detail) - data = masked_data - - roles_to_update: list[Role] | None - if "roles" in data: - roles_to_update = [] - missing_role_names = [] - for role_data in data.pop("roles", ()): - role_name = role_data["name"] - role = security_manager.find_role(role_name) - if role is None: - missing_role_names.append(role_name) - else: - roles_to_update.append(role) - if missing_role_names: - detail = f"Unknown roles: {', '.join(repr(n) for n in missing_role_names)}" - raise BadRequest(detail=detail) - else: - roles_to_update = None # Don't change existing value. - - if "password" in data: - user.password = generate_password_hash(data.pop("password")) - if roles_to_update is not None: - user.roles = roles_to_update - for key, value in data.items(): - setattr(user, key, value) - security_manager.update_user(user) - - return user_schema.dump(user) - - -@requires_access_custom_view("DELETE", permissions.RESOURCE_USER) -def delete_user(*, username: str) -> APIResponse: - """Delete a user.""" - security_manager = cast("FabAuthManager", get_auth_manager()).security_manager - - user = security_manager.find_user(username=username) - if user is None: - detail = f"The User with username `{username}` was not found" - raise NotFound(title="User not found", detail=detail) - - user.roles = [] # Clear foreign keys on this user first. - security_manager.session.delete(user) - security_manager.session.commit() - - return NoContent, HTTPStatus.NO_CONTENT diff --git a/providers/fab/src/airflow/providers/fab/auth_manager/fab_auth_manager.py b/providers/fab/src/airflow/providers/fab/auth_manager/fab_auth_manager.py index f460127ad9e44..99fcbeff134da 100644 --- a/providers/fab/src/airflow/providers/fab/auth_manager/fab_auth_manager.py +++ b/providers/fab/src/airflow/providers/fab/auth_manager/fab_auth_manager.py @@ -21,15 +21,13 @@ import warnings from contextlib import suppress from functools import cached_property -from pathlib import Path from typing import TYPE_CHECKING, Any from urllib.parse import urljoin from cachetools import TTLCache, cachedmethod -from connexion import FlaskApi from fastapi import FastAPI from fastapi.middleware.wsgi import WSGIMiddleware -from flask import Blueprint, current_app, g +from flask import current_app, g from flask_appbuilder.const import AUTH_LDAP from sqlalchemy import select from sqlalchemy.exc import NoResultFound, SQLAlchemyError @@ -62,11 +60,6 @@ from airflow.providers.fab.auth_manager.models.anonymous_user import AnonymousUser from airflow.providers.fab.version_compat import AIRFLOW_V_3_1_PLUS from airflow.providers.fab.www.app import create_app -from airflow.providers.fab.www.constants import SWAGGER_BUNDLE, SWAGGER_ENABLED -from airflow.providers.fab.www.extensions.init_views import ( - _CustomErrorRequestBodyValidator, - _LazyResolver, -) from airflow.providers.fab.www.security import permissions from airflow.providers.fab.www.security.permissions import ( ACTION_CAN_READ, @@ -100,7 +93,6 @@ get_method_from_fab_action_map, ) from airflow.utils.session import NEW_SESSION, provide_session -from airflow.utils.yaml import safe_load if TYPE_CHECKING: from airflow.api_fastapi.auth.managers.base_auth_manager import ResourceMethod @@ -252,23 +244,6 @@ async def cleanup_session_middleware(request, call_next): return app - def get_api_endpoints(self) -> None | Blueprint: - folder = Path(__file__).parents[0].resolve() # this is airflow/auth/managers/fab/ - with folder.joinpath("openapi", "v1-flask-api.yaml").open() as f: - specification = safe_load(f) - return FlaskApi( - specification=specification, - resolver=_LazyResolver(), - base_path="/fab/v1", - options={ - "swagger_ui": SWAGGER_ENABLED, - "swagger_path": SWAGGER_BUNDLE.__fspath__(), - }, - strict_validation=True, - validate_responses=True, - validator_map={"body": _CustomErrorRequestBodyValidator}, - ).blueprint - def get_user(self) -> User: """ Return the user associated to the user in session. diff --git a/providers/fab/src/airflow/providers/fab/auth_manager/openapi/__init__.py b/providers/fab/src/airflow/providers/fab/auth_manager/openapi/__init__.py deleted file mode 100644 index 13a83393a9124..0000000000000 --- a/providers/fab/src/airflow/providers/fab/auth_manager/openapi/__init__.py +++ /dev/null @@ -1,16 +0,0 @@ -# Licensed to the Apache Software Foundation (ASF) under one -# or more contributor license agreements. See the NOTICE file -# distributed with this work for additional information -# regarding copyright ownership. The ASF licenses this file -# to you under the Apache License, Version 2.0 (the -# "License"); you may not use this file except in compliance -# with the License. You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, -# software distributed under the License is distributed on an -# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY -# KIND, either express or implied. See the License for the -# specific language governing permissions and limitations -# under the License. diff --git a/providers/fab/src/airflow/providers/fab/auth_manager/openapi/v1-flask-api.yaml b/providers/fab/src/airflow/providers/fab/auth_manager/openapi/v1-flask-api.yaml deleted file mode 100644 index 4ae5bffee3efa..0000000000000 --- a/providers/fab/src/airflow/providers/fab/auth_manager/openapi/v1-flask-api.yaml +++ /dev/null @@ -1,709 +0,0 @@ -# Licensed to the Apache Software Foundation (ASF) under one -# or more contributor license agreements. See the NOTICE file -# distributed with this work for additional information -# regarding copyright ownership. The ASF licenses this file -# to you under the Apache License, Version 2.0 (the -# "License"); you may not use this file except in compliance -# with the License. You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, -# software distributed under the License is distributed on an -# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY -# KIND, either express or implied. See the License for the -# specific language governing permissions and limitations -# under the License. ---- -openapi: 3.0.3 - -info: - title: "Flask App Builder User & Role API" - - version: '1.0.0' - license: - name: Apache 2.0 - url: http://www.apache.org/licenses/LICENSE-2.0.html - contact: - name: Apache Software Foundation - url: https://airflow.apache.org - email: dev@airflow.apache.org - -paths: - /roles: - get: - summary: List roles - description: | - Get a list of roles. - - *New in version 2.8.0* - x-openapi-router-controller: airflow.providers.fab.auth_manager.api_endpoints.role_and_permission_endpoint - operationId: get_roles - tags: [Role] - parameters: - - $ref: '#/components/parameters/PageLimit' - - $ref: '#/components/parameters/PageOffset' - - $ref: '#/components/parameters/OrderBy' - responses: - '200': - description: Success. - content: - application/json: - schema: - $ref: '#/components/schemas/RoleCollection' - '401': - $ref: '#/components/responses/Unauthenticated' - '403': - $ref: '#/components/responses/PermissionDenied' - - post: - summary: Create a role - description: | - Create a new role. - - *New in version 2.8.0* - x-openapi-router-controller: airflow.providers.fab.auth_manager.api_endpoints.role_and_permission_endpoint - operationId: post_role - tags: [Role] - requestBody: - required: true - content: - application/json: - schema: - $ref: '#/components/schemas/Role' - responses: - '200': - description: Success. - content: - application/json: - schema: - $ref: '#/components/schemas/Role' - '400': - $ref: '#/components/responses/BadRequest' - '401': - $ref: '#/components/responses/Unauthenticated' - '403': - $ref: '#/components/responses/PermissionDenied' - - /roles/{role_name}: - parameters: - - $ref: '#/components/parameters/RoleName' - - get: - summary: Get a role - description: | - Get a role. - - *New in version 2.8.0* - x-openapi-router-controller: airflow.providers.fab.auth_manager.api_endpoints.role_and_permission_endpoint - operationId: get_role - tags: [Role] - responses: - '200': - description: Success. - content: - application/json: - schema: - $ref: '#/components/schemas/Role' - '401': - $ref: '#/components/responses/Unauthenticated' - '403': - $ref: '#/components/responses/PermissionDenied' - '404': - $ref: '#/components/responses/NotFound' - - patch: - summary: Update a role - description: | - Update a role. - - *New in version 2.8.0* - x-openapi-router-controller: airflow.providers.fab.auth_manager.api_endpoints.role_and_permission_endpoint - operationId: patch_role - tags: [Role] - parameters: - - $ref: '#/components/parameters/UpdateMask' - requestBody: - required: true - content: - application/json: - schema: - $ref: '#/components/schemas/Role' - - responses: - '200': - description: Success. - content: - application/json: - schema: - $ref: '#/components/schemas/Role' - '400': - $ref: '#/components/responses/BadRequest' - '401': - $ref: '#/components/responses/Unauthenticated' - '403': - $ref: '#/components/responses/PermissionDenied' - '404': - $ref: '#/components/responses/NotFound' - - delete: - summary: Delete a role - description: | - Delete a role. - - *New in version 2.8.0* - x-openapi-router-controller: airflow.providers.fab.auth_manager.api_endpoints.role_and_permission_endpoint - operationId: delete_role - tags: [Role] - responses: - '204': - description: Success. - '400': - $ref: '#/components/responses/BadRequest' - '401': - $ref: '#/components/responses/Unauthenticated' - '403': - $ref: '#/components/responses/PermissionDenied' - '404': - $ref: '#/components/responses/NotFound' - - /permissions: - get: - summary: List permissions - description: | - Get a list of permissions. - - *New in version 2.8.0* - x-openapi-router-controller: airflow.providers.fab.auth_manager.api_endpoints.role_and_permission_endpoint - operationId: get_permissions - tags: [Permission] - parameters: - - $ref: '#/components/parameters/PageLimit' - - $ref: '#/components/parameters/PageOffset' - responses: - '200': - description: Success. - content: - application/json: - schema: - $ref: '#/components/schemas/ActionCollection' - '401': - $ref: '#/components/responses/Unauthenticated' - '403': - $ref: '#/components/responses/PermissionDenied' - - /users: - get: - summary: List users - description: | - Get a list of users. - - *New in version 2.8.0* - x-openapi-router-controller: airflow.providers.fab.auth_manager.api_endpoints.user_endpoint - operationId: get_users - tags: [User] - parameters: - - $ref: '#/components/parameters/PageLimit' - - $ref: '#/components/parameters/PageOffset' - - $ref: '#/components/parameters/OrderBy' - responses: - '200': - description: Success. - content: - application/json: - schema: - $ref: '#/components/schemas/UserCollection' - '401': - $ref: '#/components/responses/Unauthenticated' - '403': - $ref: '#/components/responses/PermissionDenied' - - post: - summary: Create a user - description: | - Create a new user with unique username and email. - - *New in version 2.8.0* - x-openapi-router-controller: airflow.providers.fab.auth_manager.api_endpoints.user_endpoint - operationId: post_user - tags: [User] - requestBody: - required: true - content: - application/json: - schema: - $ref: '#/components/schemas/User' - responses: - '200': - description: Success. - content: - application/json: - schema: - $ref: '#/components/schemas/User' - '400': - $ref: '#/components/responses/BadRequest' - '401': - $ref: '#/components/responses/Unauthenticated' - '403': - $ref: '#/components/responses/PermissionDenied' - '409': - $ref: '#/components/responses/AlreadyExists' - - /users/{username}: - parameters: - - $ref: '#/components/parameters/Username' - get: - summary: Get a user - description: | - Get a user with a specific username. - - *New in version 2.8.0* - x-openapi-router-controller: airflow.providers.fab.auth_manager.api_endpoints.user_endpoint - operationId: get_user - tags: [User] - responses: - '200': - description: Success. - content: - application/json: - schema: - $ref: '#/components/schemas/UserCollectionItem' - '401': - $ref: '#/components/responses/Unauthenticated' - '403': - $ref: '#/components/responses/PermissionDenied' - '404': - $ref: '#/components/responses/NotFound' - - patch: - summary: Update a user - description: | - Update fields for a user. - - *New in version 2.8.0* - x-openapi-router-controller: airflow.providers.fab.auth_manager.api_endpoints.user_endpoint - operationId: patch_user - tags: [User] - parameters: - - $ref: '#/components/parameters/UpdateMask' - requestBody: - required: true - content: - application/json: - schema: - $ref: '#/components/schemas/User' - responses: - '200': - description: Success. - content: - application/json: - schema: - $ref: '#/components/schemas/UserCollectionItem' - '400': - $ref: '#/components/responses/BadRequest' - '401': - $ref: '#/components/responses/Unauthenticated' - '403': - $ref: '#/components/responses/PermissionDenied' - '404': - $ref: '#/components/responses/NotFound' - - delete: - summary: Delete a user - description: | - Delete a user with a specific username. - - *New in version 2.8.0* - x-openapi-router-controller: airflow.providers.fab.auth_manager.api_endpoints.user_endpoint - operationId: delete_user - tags: [User] - responses: - '204': - description: Success. - '400': - $ref: '#/components/responses/BadRequest' - '401': - $ref: '#/components/responses/Unauthenticated' - '403': - $ref: '#/components/responses/PermissionDenied' - '404': - $ref: '#/components/responses/NotFound' - -components: - # Reusable schemas (data models) - schemas: - # Database entities - UserCollectionItem: - description: | - A user object. - - *New in version 2.8.0* - type: object - properties: - first_name: - type: string - description: | - The user's first name. - last_name: - type: string - description: | - The user's last name. - username: - type: string - description: | - The username. - minLength: 1 - email: - type: string - description: | - The user's email. - minLength: 1 - active: - type: boolean - description: Whether the user is active - readOnly: true - nullable: true - last_login: - type: string - format: datetime - description: The last user login - readOnly: true - nullable: true - login_count: - type: integer - description: The login count - readOnly: true - nullable: true - failed_login_count: - type: integer - description: The number of times the login failed - readOnly: true - nullable: true - roles: - type: array - description: | - User roles. - items: - type: object - properties: - name: - type: string - nullable: true - created_on: - type: string - format: datetime - description: The date user was created - readOnly: true - nullable: true - changed_on: - type: string - format: datetime - description: The date user was changed - readOnly: true - nullable: true - User: - type: object - description: | - A user object with sensitive data. - - *New in version 2.8.0* - allOf: - - $ref: '#/components/schemas/UserCollectionItem' - - type: object - properties: - password: - type: string - writeOnly: true - - UserCollection: - type: object - description: | - Collection of users. - - *New in version 2.8.0* - allOf: - - type: object - properties: - users: - type: array - items: - $ref: '#/components/schemas/UserCollectionItem' - - $ref: '#/components/schemas/CollectionInfo' - - Role: - description: | - a role item. - - *New in version 2.8.0* - type: object - properties: - name: - type: string - description: | - The name of the role - minLength: 1 - actions: - type: array - items: - $ref: '#/components/schemas/ActionResource' - - RoleCollection: - description: | - A collection of roles. - - *New in version 2.8.0* - type: object - allOf: - - type: object - properties: - roles: - type: array - items: - $ref: '#/components/schemas/Role' - - $ref: '#/components/schemas/CollectionInfo' - - Action: - description: | - An action Item. - - *New in version 2.8.0* - type: object - properties: - name: - type: string - description: The name of the permission "action" - nullable: false - - ActionCollection: - description: | - A collection of actions. - - *New in version 2.8.0* - type: object - allOf: - - type: object - properties: - actions: - type: array - items: - $ref: '#/components/schemas/Action' - - $ref: '#/components/schemas/CollectionInfo' - - Resource: - description: | - A resource on which permissions are granted. - - *New in version 2.8.0* - type: object - properties: - name: - type: string - description: The name of the resource - nullable: false - - ActionResource: - description: | - The Action-Resource item. - - *New in version 2.8.0* - type: object - properties: - action: - type: object - $ref: '#/components/schemas/Action' - description: The permission action - resource: - type: object - $ref: '#/components/schemas/Resource' - description: The permission resource - - # Generic - Error: - description: | - [RFC7807](https://tools.ietf.org/html/rfc7807) compliant response. - type: object - properties: - type: - type: string - description: | - A URI reference [RFC3986] that identifies the problem type. This specification - encourages that, when dereferenced, it provide human-readable documentation for - the problem type. - title: - type: string - description: A short, human-readable summary of the problem type. - status: - type: number - description: The HTTP status code generated by the API server for this occurrence of the problem. - detail: - type: string - description: A human-readable explanation specific to this occurrence of the problem. - instance: - type: string - description: | - A URI reference that identifies the specific occurrence of the problem. It may or may - not yield further information if dereferenced. - required: - - type - - title - - status - - CollectionInfo: - description: Metadata about collection. - type: object - properties: - total_entries: - type: integer - description: | - Count of total objects in the current result set before pagination parameters - (limit, offset) are applied. - - - # Reusable path, query, header and cookie parameters - parameters: - # Pagination parameters - PageOffset: - in: query - name: offset - required: false - schema: - type: integer - minimum: 0 - description: The number of items to skip before starting to collect the result set. - - PageLimit: - in: query - name: limit - required: false - schema: - type: integer - default: 100 - description: The numbers of items to return. - - # Database entity fields - Username: - in: path - name: username - schema: - type: string - required: true - description: | - The username of the user. - - *New in version 2.8.0* - RoleName: - in: path - name: role_name - schema: - type: string - required: true - description: The role name - - OrderBy: - in: query - name: order_by - schema: - type: string - required: false - description: | - The name of the field to order the results by. - Prefix a field name with `-` to reverse the sort order. - - *New in version 2.8.0* - - UpdateMask: - in: query - name: update_mask - schema: - type: array - items: - type: string - description: | - The fields to update on the resource. If absent or empty, all modifiable fields are updated. - A comma-separated list of fully qualified names of fields. - style: form - explode: false - - # Reusable responses, such as 401 Unauthenticated or 400 Bad Request - responses: - # 400 - 'BadRequest': - description: Client specified an invalid argument. - content: - application/json: - schema: - $ref: '#/components/schemas/Error' - # 401 - 'Unauthenticated': - description: Request not authenticated due to missing, invalid, authentication info. - content: - application/json: - schema: - $ref: '#/components/schemas/Error' - # 403 - 'PermissionDenied': - description: Client does not have sufficient permission. - content: - application/json: - schema: - $ref: '#/components/schemas/Error' - # 404 - 'NotFound': - description: A specified resource is not found. - content: - application/json: - schema: - $ref: '#/components/schemas/Error' - # 405 - 'MethodNotAllowed': - description: Request method is known by the server but is not supported by the target resource. - content: - application/json: - schema: - $ref: '#/components/schemas/Error' - # 406 - 'NotAcceptable': - description: A specified Accept header is not allowed. - content: - application/json: - schema: - $ref: '#/components/schemas/Error' - # 409 - 'AlreadyExists': - description: An existing resource conflicts with the request. - content: - application/json: - schema: - $ref: '#/components/schemas/Error' - # 500 - 'Unknown': - description: Unknown server error. - content: - application/json: - schema: - $ref: '#/components/schemas/Error' - - securitySchemes: - Basic: - type: http - scheme: basic - description: To authenticate FAB auth manager API requests, clients have the option to use basic - authentication. To learn more about FAB auth manager API authentication, please read - https://airflow.apache.org/docs/apache-airflow-providers-fab/stable/auth-manager/api-authentication.html#basic-authentication. - GoogleOpenId: - type: openIdConnect - openIdConnectUrl: https://accounts.google.com/.well-known/openid-configuration - description: To authenticate FAB auth manager API requests, clients have the option to use Google OpenID. - To learn more about Google OpenID authentication, please read - https://airflow.apache.org/docs/apache-airflow-providers-google/stable/api-auth-backend/google-openid.html. - Kerberos: - type: http - scheme: negotiate - description: To authenticate FAB auth manager API requests, clients have the option to use Kerberos - authentication. To learn more about FAB auth manager API authentication, please read - https://airflow.apache.org/docs/apache-airflow-providers-fab/stable/auth-manager/api-authentication.html#kerberos-authentication. - -tags: - - name: Role - - name: Permission - - name: User diff --git a/providers/fab/src/airflow/providers/fab/auth_manager/schemas/__init__.py b/providers/fab/src/airflow/providers/fab/auth_manager/schemas/__init__.py deleted file mode 100644 index 13a83393a9124..0000000000000 --- a/providers/fab/src/airflow/providers/fab/auth_manager/schemas/__init__.py +++ /dev/null @@ -1,16 +0,0 @@ -# Licensed to the Apache Software Foundation (ASF) under one -# or more contributor license agreements. See the NOTICE file -# distributed with this work for additional information -# regarding copyright ownership. The ASF licenses this file -# to you under the Apache License, Version 2.0 (the -# "License"); you may not use this file except in compliance -# with the License. You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, -# software distributed under the License is distributed on an -# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY -# KIND, either express or implied. See the License for the -# specific language governing permissions and limitations -# under the License. diff --git a/providers/fab/src/airflow/providers/fab/auth_manager/schemas/role_and_permission_schema.py b/providers/fab/src/airflow/providers/fab/auth_manager/schemas/role_and_permission_schema.py deleted file mode 100644 index 756d8de6f5914..0000000000000 --- a/providers/fab/src/airflow/providers/fab/auth_manager/schemas/role_and_permission_schema.py +++ /dev/null @@ -1,103 +0,0 @@ -# Licensed to the Apache Software Foundation (ASF) under one -# or more contributor license agreements. See the NOTICE file -# distributed with this work for additional information -# regarding copyright ownership. The ASF licenses this file -# to you under the Apache License, Version 2.0 (the -# "License"); you may not use this file except in compliance -# with the License. You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, -# software distributed under the License is distributed on an -# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY -# KIND, either express or implied. See the License for the -# specific language governing permissions and limitations -# under the License. -from __future__ import annotations - -from typing import NamedTuple - -from marshmallow import Schema, fields -from marshmallow_sqlalchemy import SQLAlchemySchema, auto_field - -from airflow.providers.fab.auth_manager.models import Action, Permission, Resource, Role - - -class ActionSchema(SQLAlchemySchema): - """Action Schema.""" - - class Meta: - """Meta.""" - - model = Action - - name = auto_field() - - -class ResourceSchema(SQLAlchemySchema): - """View menu Schema.""" - - class Meta: - """Meta.""" - - model = Resource - - name = auto_field() - - -class ActionCollection(NamedTuple): - """Action Collection.""" - - actions: list[Action] - total_entries: int - - -class ActionCollectionSchema(Schema): - """Permissions list schema.""" - - actions = fields.List(fields.Nested(ActionSchema)) - total_entries = fields.Int() - - -class ActionResourceSchema(SQLAlchemySchema): - """Action View Schema.""" - - class Meta: - """Meta.""" - - model = Permission - - action = fields.Nested(ActionSchema, data_key="action") - resource = fields.Nested(ResourceSchema, data_key="resource") - - -class RoleSchema(SQLAlchemySchema): - """Role item schema.""" - - class Meta: - """Meta.""" - - model = Role - - name = auto_field() - permissions = fields.List(fields.Nested(ActionResourceSchema), data_key="actions") - - -class RoleCollection(NamedTuple): - """List of roles.""" - - roles: list[Role] - total_entries: int - - -class RoleCollectionSchema(Schema): - """List of roles.""" - - roles = fields.List(fields.Nested(RoleSchema)) - total_entries = fields.Int() - - -role_schema = RoleSchema() -role_collection_schema = RoleCollectionSchema() -action_collection_schema = ActionCollectionSchema() diff --git a/providers/fab/src/airflow/providers/fab/auth_manager/schemas/user_schema.py b/providers/fab/src/airflow/providers/fab/auth_manager/schemas/user_schema.py deleted file mode 100644 index 120698706ea16..0000000000000 --- a/providers/fab/src/airflow/providers/fab/auth_manager/schemas/user_schema.py +++ /dev/null @@ -1,73 +0,0 @@ -# Licensed to the Apache Software Foundation (ASF) under one -# or more contributor license agreements. See the NOTICE file -# distributed with this work for additional information -# regarding copyright ownership. The ASF licenses this file -# to you under the Apache License, Version 2.0 (the -# "License"); you may not use this file except in compliance -# with the License. You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, -# software distributed under the License is distributed on an -# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY -# KIND, either express or implied. See the License for the -# specific language governing permissions and limitations -# under the License. -from __future__ import annotations - -from typing import NamedTuple - -from marshmallow import Schema, fields -from marshmallow_sqlalchemy import SQLAlchemySchema, auto_field - -from airflow.providers.fab.auth_manager.models import User -from airflow.providers.fab.auth_manager.schemas.role_and_permission_schema import RoleSchema -from airflow.providers.fab.www.api_connexion.parameters import validate_istimezone - - -class UserCollectionItemSchema(SQLAlchemySchema): - """user collection item schema.""" - - class Meta: - """Meta.""" - - model = User - dateformat = "iso" - - first_name = auto_field() - last_name = auto_field() - username = auto_field() - active = auto_field(dump_only=True) - email = auto_field() - last_login = auto_field(dump_only=True) - login_count = auto_field(dump_only=True) - fail_login_count = auto_field(dump_only=True) - roles = fields.List(fields.Nested(RoleSchema, only=("name",))) - created_on = auto_field(validate=validate_istimezone, dump_only=True) - changed_on = auto_field(validate=validate_istimezone, dump_only=True) - - -class UserSchema(UserCollectionItemSchema): - """User schema.""" - - password = auto_field(load_only=True) - - -class UserCollection(NamedTuple): - """User collection.""" - - users: list[User] - total_entries: int - - -class UserCollectionSchema(Schema): - """User collection schema.""" - - users = fields.List(fields.Nested(UserCollectionItemSchema)) - total_entries = fields.Int() - - -user_collection_item_schema = UserCollectionItemSchema() -user_schema = UserSchema() -user_collection_schema = UserCollectionSchema() diff --git a/providers/fab/src/airflow/providers/fab/www/api_connexion/__init__.py b/providers/fab/src/airflow/providers/fab/www/api_connexion/__init__.py deleted file mode 100644 index 217e5db960782..0000000000000 --- a/providers/fab/src/airflow/providers/fab/www/api_connexion/__init__.py +++ /dev/null @@ -1,17 +0,0 @@ -# -# Licensed to the Apache Software Foundation (ASF) under one -# or more contributor license agreements. See the NOTICE file -# distributed with this work for additional information -# regarding copyright ownership. The ASF licenses this file -# to you under the Apache License, Version 2.0 (the -# "License"); you may not use this file except in compliance -# with the License. You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, -# software distributed under the License is distributed on an -# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY -# KIND, either express or implied. See the License for the -# specific language governing permissions and limitations -# under the License. diff --git a/providers/fab/src/airflow/providers/fab/www/api_connexion/exceptions.py b/providers/fab/src/airflow/providers/fab/www/api_connexion/exceptions.py deleted file mode 100644 index ef2e2ab9b4bbc..0000000000000 --- a/providers/fab/src/airflow/providers/fab/www/api_connexion/exceptions.py +++ /dev/null @@ -1,197 +0,0 @@ -# Licensed to the Apache Software Foundation (ASF) under one -# or more contributor license agreements. See the NOTICE file -# distributed with this work for additional information -# regarding copyright ownership. The ASF licenses this file -# to you under the Apache License, Version 2.0 (the -# "License"); you may not use this file except in compliance -# with the License. You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, -# software distributed under the License is distributed on an -# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY -# KIND, either express or implied. See the License for the -# specific language governing permissions and limitations -# under the License. -from __future__ import annotations - -from http import HTTPStatus -from typing import TYPE_CHECKING, Any - -import werkzeug -from connexion import FlaskApi, ProblemException, problem - -from airflow.utils.docs import get_docs_url - -if TYPE_CHECKING: - import flask - -doc_link = get_docs_url("stable-rest-api-ref.html") - -EXCEPTIONS_LINK_MAP = { - 400: f"{doc_link}#section/Errors/BadRequest", - 404: f"{doc_link}#section/Errors/NotFound", - 405: f"{doc_link}#section/Errors/MethodNotAllowed", - 401: f"{doc_link}#section/Errors/Unauthenticated", - 409: f"{doc_link}#section/Errors/AlreadyExists", - 403: f"{doc_link}#section/Errors/PermissionDenied", - 500: f"{doc_link}#section/Errors/Unknown", -} - - -def common_error_handler(exception: BaseException) -> flask.Response: - """Use to capture connexion exceptions and add link to the type field.""" - if isinstance(exception, ProblemException): - link = EXCEPTIONS_LINK_MAP.get(exception.status) - if link: - response = problem( - status=exception.status, - title=exception.title, - detail=exception.detail, - type=link, - instance=exception.instance, - headers=exception.headers, - ext=exception.ext, - ) - else: - response = problem( - status=exception.status, - title=exception.title, - detail=exception.detail, - type=exception.type, - instance=exception.instance, - headers=exception.headers, - ext=exception.ext, - ) - else: - if not isinstance(exception, werkzeug.exceptions.HTTPException): - exception = werkzeug.exceptions.InternalServerError() - - response = problem(title=exception.name, detail=exception.description, status=exception.code) - - return FlaskApi.get_response(response) - - -class NotFound(ProblemException): - """Raise when the object cannot be found.""" - - def __init__( - self, - title: str = "Not Found", - detail: str | None = None, - headers: dict | None = None, - **kwargs: Any, - ) -> None: - super().__init__( - status=HTTPStatus.NOT_FOUND, - type=EXCEPTIONS_LINK_MAP[404], - title=title, - detail=detail, - headers=headers, - **kwargs, - ) - - -class BadRequest(ProblemException): - """Raise when the server processes a bad request.""" - - def __init__( - self, - title: str = "Bad Request", - detail: str | None = None, - headers: dict | None = None, - **kwargs: Any, - ) -> None: - super().__init__( - status=HTTPStatus.BAD_REQUEST, - type=EXCEPTIONS_LINK_MAP[400], - title=title, - detail=detail, - headers=headers, - **kwargs, - ) - - -class Unauthenticated(ProblemException): - """Raise when the user is not authenticated.""" - - def __init__( - self, - title: str = "Unauthorized", - detail: str | None = None, - headers: dict | None = None, - **kwargs: Any, - ): - super().__init__( - status=HTTPStatus.UNAUTHORIZED, - type=EXCEPTIONS_LINK_MAP[401], - title=title, - detail=detail, - headers=headers, - **kwargs, - ) - - -class PermissionDenied(ProblemException): - """Raise when the user does not have the required permissions.""" - - def __init__( - self, - title: str = "Forbidden", - detail: str | None = None, - headers: dict | None = None, - **kwargs: Any, - ) -> None: - super().__init__( - status=HTTPStatus.FORBIDDEN, - type=EXCEPTIONS_LINK_MAP[403], - title=title, - detail=detail, - headers=headers, - **kwargs, - ) - - -class Conflict(ProblemException): - """Raise when there is some conflict.""" - - def __init__( - self, - title="Conflict", - detail: str | None = None, - headers: dict | None = None, - **kwargs: Any, - ): - super().__init__( - status=HTTPStatus.CONFLICT, - type=EXCEPTIONS_LINK_MAP[409], - title=title, - detail=detail, - headers=headers, - **kwargs, - ) - - -class AlreadyExists(Conflict): - """Raise when the object already exists.""" - - -class Unknown(ProblemException): - """Returns a response body and status code for HTTP 500 exception.""" - - def __init__( - self, - title: str = "Internal Server Error", - detail: str | None = None, - headers: dict | None = None, - **kwargs: Any, - ) -> None: - super().__init__( - status=HTTPStatus.INTERNAL_SERVER_ERROR, - type=EXCEPTIONS_LINK_MAP[500], - title=title, - detail=detail, - headers=headers, - **kwargs, - ) diff --git a/providers/fab/src/airflow/providers/fab/www/api_connexion/parameters.py b/providers/fab/src/airflow/providers/fab/www/api_connexion/parameters.py deleted file mode 100644 index 3bfde920f9e6e..0000000000000 --- a/providers/fab/src/airflow/providers/fab/www/api_connexion/parameters.py +++ /dev/null @@ -1,86 +0,0 @@ -# Licensed to the Apache Software Foundation (ASF) under one -# or more contributor license agreements. See the NOTICE file -# distributed with this work for additional information -# regarding copyright ownership. The ASF licenses this file -# to you under the Apache License, Version 2.0 (the -# "License"); you may not use this file except in compliance -# with the License. You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, -# software distributed under the License is distributed on an -# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY -# KIND, either express or implied. See the License for the -# specific language governing permissions and limitations -# under the License. -from __future__ import annotations - -import logging -from collections.abc import Callable -from functools import wraps -from typing import TYPE_CHECKING, Any, TypeVar, cast - -from airflow.configuration import conf -from airflow.providers.fab.www.api_connexion.exceptions import BadRequest - -if TYPE_CHECKING: - from datetime import datetime - - -log = logging.getLogger(__name__) - - -def validate_istimezone(value: datetime) -> None: - """Validate that a datetime is not naive.""" - if not value.tzinfo: - raise BadRequest("Invalid datetime format", detail="Naive datetime is disallowed") - - -def check_limit(value: int) -> int: - """ - Check the limit does not exceed configured value. - - This checks the limit passed to view and raises BadRequest if - limit exceed user configured value - """ - max_val = conf.getint("api", "maximum_page_limit") # user configured max page limit - fallback = conf.getint("api", "fallback_page_limit") - - if value > max_val: - log.warning( - "The limit param value %s passed in API exceeds the configured maximum page limit %s", - value, - max_val, - ) - return max_val - if value == 0: - return fallback - if value < 0: - raise BadRequest("Page limit must be a positive integer") - return value - - -T = TypeVar("T", bound=Callable) - - -def format_parameters(params_formatters: dict[str, Callable[[Any], Any]]) -> Callable[[T], T]: - """ - Create a decorator to convert parameters using given formatters. - - Using it allows you to separate parameter formatting from endpoint logic. - - :param params_formatters: Map of key name and formatter function - """ - - def format_parameters_decorator(func: T) -> T: - @wraps(func) - def wrapped_function(*args, **kwargs): - for key, formatter in params_formatters.items(): - if key in kwargs: - kwargs[key] = formatter(kwargs[key]) - return func(*args, **kwargs) - - return cast("T", wrapped_function) - - return format_parameters_decorator diff --git a/providers/fab/src/airflow/providers/fab/www/api_connexion/security.py b/providers/fab/src/airflow/providers/fab/www/api_connexion/security.py deleted file mode 100644 index b1ed503e52b34..0000000000000 --- a/providers/fab/src/airflow/providers/fab/www/api_connexion/security.py +++ /dev/null @@ -1,85 +0,0 @@ -# Licensed to the Apache Software Foundation (ASF) under one -# or more contributor license agreements. See the NOTICE file -# distributed with this work for additional information -# regarding copyright ownership. The ASF licenses this file -# to you under the Apache License, Version 2.0 (the -# "License"); you may not use this file except in compliance -# with the License. You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, -# software distributed under the License is distributed on an -# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY -# KIND, either express or implied. See the License for the -# specific language governing permissions and limitations -# under the License. -from __future__ import annotations - -from collections.abc import Callable -from functools import wraps -from typing import TYPE_CHECKING, TypeVar, cast - -from flask import Response, current_app - -from airflow.api_fastapi.app import get_auth_manager -from airflow.providers.fab.www.api_connexion.exceptions import PermissionDenied, Unauthenticated - -if TYPE_CHECKING: - from airflow.api_fastapi.auth.managers.base_auth_manager import ResourceMethod - from airflow.providers.fab.www.airflow_flask_app import AirflowApp - -T = TypeVar("T", bound=Callable) - - -def check_authentication() -> None: - """Check that the request has valid authorization information.""" - for auth in cast("AirflowApp", current_app).api_auth: - response = auth.requires_authentication(Response)() - if response.status_code == 200: - return - - # since this handler only checks authentication, not authorization, - # we should always return 401 - raise Unauthenticated(headers=response.headers) - - -def _requires_access(*, is_authorized_callback: Callable[[], bool], func: Callable, args, kwargs) -> bool: - """ - Define the behavior whether the user is authorized to access the resource. - - :param is_authorized_callback: callback to execute to figure whether the user is authorized to access - the resource - :param func: the function to call if the user is authorized - :param args: the arguments of ``func`` - :param kwargs: the keyword arguments ``func`` - - :meta private: - """ - check_authentication() - if is_authorized_callback(): - return func(*args, **kwargs) - raise PermissionDenied() - - -def requires_access_custom_view( - method: ResourceMethod, - resource_name: str, -) -> Callable[[T], T]: - def requires_access_decorator(func: T): - @wraps(func) - def decorated(*args, **kwargs): - return _requires_access( - is_authorized_callback=lambda: get_auth_manager().is_authorized_custom_view( - method=method, - resource_name=resource_name, - user=get_auth_manager().get_user(), - ), - func=func, - args=args, - kwargs=kwargs, - ) - - return cast("T", decorated) - - return requires_access_decorator diff --git a/providers/fab/src/airflow/providers/fab/www/api_connexion/types.py b/providers/fab/src/airflow/providers/fab/www/api_connexion/types.py deleted file mode 100644 index 706436a807417..0000000000000 --- a/providers/fab/src/airflow/providers/fab/www/api_connexion/types.py +++ /dev/null @@ -1,28 +0,0 @@ -# Licensed to the Apache Software Foundation (ASF) under one -# or more contributor license agreements. See the NOTICE file -# distributed with this work for additional information -# regarding copyright ownership. The ASF licenses this file -# to you under the Apache License, Version 2.0 (the -# "License"); you may not use this file except in compliance -# with the License. You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, -# software distributed under the License is distributed on an -# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY -# KIND, either express or implied. See the License for the -# specific language governing permissions and limitations -# under the License. -from __future__ import annotations - -from collections.abc import Mapping, Sequence -from typing import Any - -from flask import Response - -# tuple[object, int] For '(NoContent, 201)'. -# Mapping[str, Any] for json -APIResponse = Response | tuple[object, int] | Mapping[str, Any] - -UpdateMask = Sequence[str] | None diff --git a/providers/fab/src/airflow/providers/fab/www/app.py b/providers/fab/src/airflow/providers/fab/www/app.py index f9c34726d1c51..3e99c6447649b 100644 --- a/providers/fab/src/airflow/providers/fab/www/app.py +++ b/providers/fab/src/airflow/providers/fab/www/app.py @@ -38,8 +38,6 @@ from airflow.providers.fab.www.extensions.init_security import init_api_auth from airflow.providers.fab.www.extensions.init_session import init_airflow_session_interface from airflow.providers.fab.www.extensions.init_views import ( - init_api_auth_provider, - init_api_error_handlers, init_error_handlers, init_plugins, ) @@ -112,8 +110,6 @@ def create_app(enable_plugins: bool): if enable_plugins: init_plugins(flask_app) elif isinstance(get_auth_manager(), FabAuthManager): - init_api_auth_provider(flask_app) - init_api_error_handlers(flask_app) init_airflow_session_interface(flask_app, db) init_jinja_globals(flask_app, enable_plugins=enable_plugins) init_wsgi_middleware(flask_app) diff --git a/providers/fab/src/airflow/providers/fab/www/constants.py b/providers/fab/src/airflow/providers/fab/www/constants.py index 69e1a1965bf96..e12a32bcabb82 100644 --- a/providers/fab/src/airflow/providers/fab/www/constants.py +++ b/providers/fab/src/airflow/providers/fab/www/constants.py @@ -18,11 +18,4 @@ from pathlib import Path -from airflow.configuration import conf - WWW = Path(__file__).resolve().parent -# There is a difference with configuring Swagger in Connexion 2.x and Connexion 3.x -# Connexion 2: https://connexion.readthedocs.io/en/2.14.2/quickstart.html#the-swagger-ui-console -# Connexion 3: https://connexion.readthedocs.io/en/stable/swagger_ui.html#configuring-the-swagger-ui -SWAGGER_ENABLED = conf.getboolean("api", "enable_swagger_ui", fallback=True) -SWAGGER_BUNDLE = WWW.joinpath("static", "dist", "swagger-ui") diff --git a/providers/fab/src/airflow/providers/fab/www/extensions/init_views.py b/providers/fab/src/airflow/providers/fab/www/extensions/init_views.py index f26b698161513..f83825f81c00b 100644 --- a/providers/fab/src/airflow/providers/fab/www/extensions/init_views.py +++ b/providers/fab/src/airflow/providers/fab/www/extensions/init_views.py @@ -17,20 +17,8 @@ from __future__ import annotations import logging -from functools import cached_property -from typing import TYPE_CHECKING -from connexion import Resolver -from connexion.decorators.validation import RequestBodyValidator -from connexion.exceptions import BadRequestProblem, ProblemException -from flask import request - -from airflow.api_fastapi.app import get_auth_manager from airflow.providers.fab.version_compat import AIRFLOW_V_3_1_PLUS, AIRFLOW_V_3_2_PLUS -from airflow.providers.fab.www.api_connexion.exceptions import common_error_handler - -if TYPE_CHECKING: - from flask import Flask log = logging.getLogger(__name__) @@ -50,54 +38,6 @@ def init_appbuilder_views(app): appbuilder.add_view_no_menu(views.FabIndexView()) -class _LazyResolution: - """ - OpenAPI endpoint that lazily resolves the function on first use. - - This is a stand-in replacement for ``connexion.Resolution`` that implements - its public attributes ``function`` and ``operation_id``, but the function - is only resolved when it is first accessed. - """ - - def __init__(self, resolve_func, operation_id): - self._resolve_func = resolve_func - self.operation_id = operation_id - - @cached_property - def function(self): - return self._resolve_func(self.operation_id) - - -class _LazyResolver(Resolver): - """ - OpenAPI endpoint resolver that loads lazily on first use. - - This re-implements ``connexion.Resolver.resolve()`` to not eagerly resolve - the endpoint function (and thus avoid importing it in the process), but only - return a placeholder that will be actually resolved when the contained - function is accessed. - """ - - def resolve(self, operation): - operation_id = self.resolve_operation_id(operation) - return _LazyResolution(self.resolve_function_from_operation_id, operation_id) - - -class _CustomErrorRequestBodyValidator(RequestBodyValidator): - """ - Custom request body validator that overrides error messages. - - By default, Connextion emits a very generic *None is not of type 'object'* - error when receiving an empty request body (with the view specifying the - body as non-nullable). We overrides it to provide a more useful message. - """ - - def validate_schema(self, data, url): - if not self.is_null_value_valid and data is None: - raise BadRequestProblem(detail="Request body must not be empty") - return super().validate_schema(data, url) - - def init_plugins(app): """Integrate Flask and FAB with plugins.""" from airflow import plugins_manager @@ -139,45 +79,9 @@ def init_plugins(app): app.register_blueprint(blue_print["blueprint"]) -base_paths: list[str] = [] # contains the list of base paths that have api endpoints - - -def init_api_error_handlers(app: Flask) -> None: - """Add error handlers for 404 and 405 errors for existing API paths.""" - from airflow.providers.fab.www import views - - @app.errorhandler(404) - def _handle_api_not_found(ex): - if any([request.path.startswith(p) for p in base_paths]): - # 404 errors are never handled on the blueprint level - # unless raised from a view func so actual 404 errors, - # i.e. "no route for it" defined, need to be handled - # here on the application level - return common_error_handler(ex) - return views.not_found(ex) - - @app.errorhandler(405) - def _handle_method_not_allowed(ex): - if any([request.path.startswith(p) for p in base_paths]): - return common_error_handler(ex) - return views.method_not_allowed(ex) - - app.register_error_handler(ProblemException, common_error_handler) - - -def init_error_handlers(app: Flask): +def init_error_handlers(app): """Add custom errors handlers.""" from airflow.providers.fab.www import views app.register_error_handler(500, views.show_traceback) app.register_error_handler(404, views.not_found) - - -def init_api_auth_provider(app): - """Initialize the API offered by the auth manager.""" - auth_mgr = get_auth_manager() - blueprint = auth_mgr.get_api_endpoints() - if blueprint: - base_paths.append(blueprint.url_prefix) - app.register_blueprint(blueprint) - app.extensions["csrf"].exempt(blueprint) diff --git a/providers/fab/tests/unit/fab/auth_manager/api_endpoints/__init__.py b/providers/fab/tests/unit/fab/auth_manager/api_endpoints/__init__.py deleted file mode 100644 index 13a83393a9124..0000000000000 --- a/providers/fab/tests/unit/fab/auth_manager/api_endpoints/__init__.py +++ /dev/null @@ -1,16 +0,0 @@ -# Licensed to the Apache Software Foundation (ASF) under one -# or more contributor license agreements. See the NOTICE file -# distributed with this work for additional information -# regarding copyright ownership. The ASF licenses this file -# to you under the Apache License, Version 2.0 (the -# "License"); you may not use this file except in compliance -# with the License. You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, -# software distributed under the License is distributed on an -# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY -# KIND, either express or implied. See the License for the -# specific language governing permissions and limitations -# under the License. diff --git a/providers/fab/tests/unit/fab/auth_manager/api_endpoints/remote_user_api_auth_backend.py b/providers/fab/tests/unit/fab/auth_manager/api_endpoints/remote_user_api_auth_backend.py deleted file mode 100644 index 976c9123e25e2..0000000000000 --- a/providers/fab/tests/unit/fab/auth_manager/api_endpoints/remote_user_api_auth_backend.py +++ /dev/null @@ -1,84 +0,0 @@ -# -# Licensed to the Apache Software Foundation (ASF) under one -# or more contributor license agreements. See the NOTICE file -# distributed with this work for additional information -# regarding copyright ownership. The ASF licenses this file -# to you under the Apache License, Version 2.0 (the -# "License"); you may not use this file except in compliance -# with the License. You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, -# software distributed under the License is distributed on an -# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY -# KIND, either express or implied. See the License for the -# specific language governing permissions and limitations -# under the License. -"""Default authentication backend - everything is allowed""" - -from __future__ import annotations - -import logging -from collections.abc import Callable -from functools import wraps -from typing import TYPE_CHECKING, TypeVar, cast - -from flask import Response, request -from flask_login import login_user - -from airflow.api_fastapi.app import get_auth_manager - -if TYPE_CHECKING: - from requests.auth import AuthBase - - from airflow.providers.fab.auth_manager.fab_auth_manager import FabAuthManager - -log = logging.getLogger(__name__) - -CLIENT_AUTH: tuple[str, str] | AuthBase | None = None - - -def init_app(_): - """Initializes authentication backend""" - - -T = TypeVar("T", bound=Callable) - - -def _lookup_user(user_email_or_username: str): - security_manager = cast("FabAuthManager", get_auth_manager()).security_manager - user = security_manager.find_user(email=user_email_or_username) or security_manager.find_user( - username=user_email_or_username - ) - if not user: - return None - - if not user.is_active: - return None - - return user - - -def requires_authentication(function: T): - """Decorator for functions that require authentication""" - - @wraps(function) - def decorated(*args, **kwargs): - user_id = request.remote_user - if not user_id: - log.debug("Missing REMOTE_USER.") - return Response("Forbidden", 403) - - log.debug("Looking for user: %s", user_id) - - user = _lookup_user(user_id) - if not user: - return Response("Forbidden", 403) - - log.debug("Found user: %s", user) - - login_user(user, remember=False) - return function(*args, **kwargs) - - return cast("T", decorated) diff --git a/providers/fab/tests/unit/fab/auth_manager/api_endpoints/test_auth.py b/providers/fab/tests/unit/fab/auth_manager/api_endpoints/test_auth.py deleted file mode 100644 index c59c7dcf78221..0000000000000 --- a/providers/fab/tests/unit/fab/auth_manager/api_endpoints/test_auth.py +++ /dev/null @@ -1,77 +0,0 @@ -# Licensed to the Apache Software Foundation (ASF) under one -# or more contributor license agreements. See the NOTICE file -# distributed with this work for additional information -# regarding copyright ownership. The ASF licenses this file -# to you under the Apache License, Version 2.0 (the -# "License"); you may not use this file except in compliance -# with the License. You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, -# software distributed under the License is distributed on an -# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY -# KIND, either express or implied. See the License for the -# specific language governing permissions and limitations -# under the License. -from __future__ import annotations - -from base64 import b64encode - -import pytest - -from tests_common.test_utils.config import conf_vars -from tests_common.test_utils.db import clear_db_pools -from tests_common.test_utils.version_compat import AIRFLOW_V_3_0_PLUS -from unit.fab.auth_manager.api_endpoints.api_connexion_utils import ( - delete_user, -) - -pytestmark = [ - pytest.mark.db_test, - pytest.mark.skipif(not AIRFLOW_V_3_0_PLUS, reason="Test requires Airflow 3.0+"), -] - - -class BaseTestAuth: - @pytest.fixture(autouse=True) - def set_attrs(self, minimal_app_for_auth_api): - self.app = minimal_app_for_auth_api - - sm = self.app.appbuilder.sm - delete_user(self.app, "test") - role_admin = sm.find_role("Admin") - sm.add_user( - username="test", - first_name="test", - last_name="test", - email="test@fab.org", - role=role_admin, - password="test", - ) - - -class TestBasicAuth(BaseTestAuth): - @pytest.fixture(autouse=True, scope="class") - def with_basic_auth_backend(self, minimal_app_for_auth_api): - from airflow.providers.fab.www.extensions.init_security import init_api_auth - - old_auth = getattr(minimal_app_for_auth_api, "api_auth") - - try: - with conf_vars( - {("fab", "auth_backends"): "airflow.providers.fab.auth_manager.api.auth.backend.basic_auth"} - ): - init_api_auth(minimal_app_for_auth_api) - yield - finally: - setattr(minimal_app_for_auth_api, "api_auth", old_auth) - - def test_success(self): - token = "Basic " + b64encode(b"test:test").decode() - clear_db_pools() - - with self.app.test_client() as test_client: - response = test_client.get("/fab/v1/users", headers={"Authorization": token}) - - assert response.status_code == 200 diff --git a/providers/fab/tests/unit/fab/auth_manager/api_endpoints/test_role_and_permission_endpoint.py b/providers/fab/tests/unit/fab/auth_manager/api_endpoints/test_role_and_permission_endpoint.py deleted file mode 100644 index 8db5c70d24eed..0000000000000 --- a/providers/fab/tests/unit/fab/auth_manager/api_endpoints/test_role_and_permission_endpoint.py +++ /dev/null @@ -1,572 +0,0 @@ -# Licensed to the Apache Software Foundation (ASF) under one -# or more contributor license agreements. See the NOTICE file -# distributed with this work for additional information -# regarding copyright ownership. The ASF licenses this file -# to you under the Apache License, Version 2.0 (the -# "License"); you may not use this file except in compliance -# with the License. You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, -# software distributed under the License is distributed on an -# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY -# KIND, either express or implied. See the License for the -# specific language governing permissions and limitations -# under the License. -from __future__ import annotations - -import pytest -from sqlalchemy import select - -from airflow.providers.fab.auth_manager.models import Role -from airflow.providers.fab.auth_manager.security_manager.override import EXISTING_ROLES -from airflow.providers.fab.www.api_connexion.exceptions import EXCEPTIONS_LINK_MAP -from airflow.providers.fab.www.security import permissions - -from unit.fab.auth_manager.api_endpoints.api_connexion_utils import ( - assert_401, - create_role, - create_user, - delete_role, - delete_user, -) - -pytestmark = pytest.mark.db_test - - -@pytest.fixture -def configured_app(minimal_app_for_auth_api): - app = minimal_app_for_auth_api - with app.app_context(): - create_user( - app, - username="test", - role_name="Test", - permissions=[ - (permissions.ACTION_CAN_CREATE, permissions.RESOURCE_ROLE), - (permissions.ACTION_CAN_READ, permissions.RESOURCE_ROLE), - (permissions.ACTION_CAN_EDIT, permissions.RESOURCE_ROLE), - (permissions.ACTION_CAN_DELETE, permissions.RESOURCE_ROLE), - (permissions.ACTION_CAN_READ, permissions.RESOURCE_ACTION), - ], - ) - create_user(app, username="test_no_permissions", role_name="TestNoPermissions") - yield app - - delete_user(app, username="test") - delete_user(app, username="test_no_permissions") - session = app.appbuilder.session - existing_roles = set(EXISTING_ROLES) - existing_roles.update(["Test", "TestNoPermissions"]) - roles = session.scalars(select(Role).where(~Role.name.in_(existing_roles))).unique().all() - for role in roles: - delete_role(app, role.name) - - -class TestRoleEndpoint: - @pytest.fixture(autouse=True) - def setup_attrs(self, configured_app) -> None: - self.app = configured_app - self.client = self.app.test_client() - - -class TestGetRoleEndpoint(TestRoleEndpoint): - def test_should_response_200(self): - response = self.client.get("/fab/v1/roles/Admin", environ_overrides={"REMOTE_USER": "test"}) - assert response.status_code == 200 - assert response.json["name"] == "Admin" - - def test_should_respond_404(self): - response = self.client.get("/fab/v1/roles/invalid-role", environ_overrides={"REMOTE_USER": "test"}) - assert response.status_code == 404 - assert response.json == { - "detail": "Role with name 'invalid-role' was not found", - "status": 404, - "title": "Role not found", - "type": EXCEPTIONS_LINK_MAP[404], - } - - def test_should_raises_401_unauthenticated(self): - response = self.client.get("/fab/v1/roles/Admin") - assert_401(response) - - def test_should_raise_403_forbidden(self): - response = self.client.get( - "/fab/v1/roles/Admin", environ_overrides={"REMOTE_USER": "test_no_permissions"} - ) - assert response.status_code == 403 - - @pytest.mark.parametrize( - ("set_auth_role_public", "expected_status_code"), - (("Public", 403), ("Admin", 200)), - indirect=["set_auth_role_public"], - ) - def test_with_auth_role_public_set(self, set_auth_role_public, expected_status_code): - response = self.client.get("/fab/v1/roles/Admin") - assert response.status_code == expected_status_code, response.json - - -class TestGetRolesEndpoint(TestRoleEndpoint): - def test_should_response_200(self): - response = self.client.get("/fab/v1/roles", environ_overrides={"REMOTE_USER": "test"}) - assert response.status_code == 200 - existing_roles = set(EXISTING_ROLES) - existing_roles.update(["Test", "TestNoPermissions"]) - assert response.json["total_entries"] == len(existing_roles) - roles = {role["name"] for role in response.json["roles"]} - assert roles == existing_roles - - def test_should_raises_401_unauthenticated(self): - response = self.client.get("/fab/v1/roles") - assert_401(response) - - def test_should_raises_400_for_invalid_order_by(self): - response = self.client.get( - "/fab/v1/roles?order_by=invalid", environ_overrides={"REMOTE_USER": "test"} - ) - assert response.status_code == 400 - msg = "Ordering with 'invalid' is disallowed or the attribute does not exist on the model" - assert response.json["detail"] == msg - - def test_should_raise_403_forbidden(self): - response = self.client.get("/fab/v1/roles", environ_overrides={"REMOTE_USER": "test_no_permissions"}) - assert response.status_code == 403 - - @pytest.mark.parametrize( - ("set_auth_role_public", "expected_status_code"), - (("Public", 403), ("Admin", 200)), - indirect=["set_auth_role_public"], - ) - def test_with_auth_role_public_set(self, set_auth_role_public, expected_status_code): - response = self.client.get("/fab/v1/roles") - assert response.status_code == expected_status_code, response.json - - -class TestGetRolesEndpointPaginationandFilter(TestRoleEndpoint): - @pytest.mark.parametrize( - ("url", "expected_roles"), - [ - ("/fab/v1/roles?limit=1", ["Admin"]), - ("/fab/v1/roles?limit=2", ["Admin", "Op"]), - ( - "/fab/v1/roles?offset=1", - ["Op", "Public", "Test", "TestNoPermissions", "User", "Viewer"], - ), - ( - "/fab/v1/roles?offset=0", - ["Admin", "Op", "Public", "Test", "TestNoPermissions", "User", "Viewer"], - ), - ("/fab/v1/roles?limit=1&offset=2", ["Public"]), - ("/fab/v1/roles?limit=1&offset=1", ["Op"]), - ( - "/fab/v1/roles?limit=2&offset=2", - ["Public", "Test"], - ), - ], - ) - def test_can_handle_limit_and_offset(self, url, expected_roles): - response = self.client.get(url, environ_overrides={"REMOTE_USER": "test"}) - assert response.status_code == 200 - existing_roles = set(EXISTING_ROLES) - existing_roles.update(["Test", "TestNoPermissions"]) - assert response.json["total_entries"] == len(existing_roles) - roles = [role["name"] for role in response.json["roles"] if role] - - assert roles == expected_roles - - -class TestGetPermissionsEndpoint(TestRoleEndpoint): - def test_should_response_200(self): - response = self.client.get("/fab/v1/permissions", environ_overrides={"REMOTE_USER": "test"}) - actions = {i[0] for i in self.app.appbuilder.sm.get_all_permissions() if i} - assert response.status_code == 200 - assert response.json["total_entries"] == len(actions) - returned_actions = {perm["name"] for perm in response.json["actions"]} - assert actions == returned_actions - - def test_should_raises_401_unauthenticated(self): - response = self.client.get("/fab/v1/permissions") - assert_401(response) - - def test_should_raise_403_forbidden(self): - response = self.client.get( - "/fab/v1/permissions", environ_overrides={"REMOTE_USER": "test_no_permissions"} - ) - assert response.status_code == 403 - - @pytest.mark.parametrize( - ("set_auth_role_public", "expected_status_code"), - (("Public", 403), ("Admin", 200)), - indirect=["set_auth_role_public"], - ) - def test_with_auth_role_public_set(self, set_auth_role_public, expected_status_code): - response = self.client.get("/fab/v1/permissions") - assert response.status_code == expected_status_code, response.json - - -class TestPostRole(TestRoleEndpoint): - def test_post_should_respond_200(self): - payload = { - "name": "Test2", - "actions": [{"resource": {"name": "Connections"}, "action": {"name": "can_create"}}], - } - response = self.client.post("/fab/v1/roles", json=payload, environ_overrides={"REMOTE_USER": "test"}) - assert response.status_code == 200 - role = self.app.appbuilder.sm.find_role("Test2") - assert role is not None - - @pytest.mark.parametrize( - ("payload", "error_message"), - [ - ( - { - "actions": [{"resource": {"name": "Connections"}, "action": {"name": "can_create"}}], - }, - "{'name': ['Missing data for required field.']}", - ), - ( - { - "name": "TestRole", - "actionss": [ - { - "resource": {"name": "Connections"}, # actionss not correct - "action": {"name": "can_create"}, - } - ], - }, - "{'actionss': ['Unknown field.']}", - ), - ( - { - "name": "TestRole", - "actions": [ - { - "resources": {"name": "Connections"}, # resources is invalid, should be resource - "action": {"name": "can_create"}, - } - ], - }, - "{'actions': {0: {'resources': ['Unknown field.']}}}", - ), - ( - { - "name": "TestRole", - "actions": [ - {"resource": {"name": "Connections"}, "actions": {"name": "can_create"}} - ], # actions is invalid, should be action - }, - "{'actions': {0: {'actions': ['Unknown field.']}}}", - ), - ( - { - "name": "TestRole", - "actions": [ - { - "resource": {"name": "FooBars"}, # FooBars is not a resource - "action": {"name": "can_create"}, - } - ], - }, - "The specified resource: 'FooBars' was not found", - ), - ( - { - "name": "TestRole", - "actions": [ - {"resource": {"name": "Connections"}, "action": {"name": "can_amend"}} - ], # can_amend is not an action - }, - "The specified action: 'can_amend' was not found", - ), - ], - ) - def test_post_should_respond_400_for_invalid_payload(self, payload, error_message): - response = self.client.post("/fab/v1/roles", json=payload, environ_overrides={"REMOTE_USER": "test"}) - assert response.status_code == 400 - assert response.json == { - "detail": error_message, - "status": 400, - "title": "Bad Request", - "type": EXCEPTIONS_LINK_MAP[400], - } - - def test_post_should_respond_409_already_exist(self): - payload = { - "name": "Test", - "actions": [{"resource": {"name": "Connections"}, "action": {"name": "can_create"}}], - } - response = self.client.post("/fab/v1/roles", json=payload, environ_overrides={"REMOTE_USER": "test"}) - assert response.status_code == 409 - assert response.json == { - "detail": "Role with name 'Test' already exists; please update with the PATCH endpoint", - "status": 409, - "title": "Conflict", - "type": EXCEPTIONS_LINK_MAP[409], - } - - def test_should_raises_401_unauthenticated(self): - response = self.client.post( - "/fab/v1/roles", - json={ - "name": "Test2", - "actions": [{"resource": {"name": "Connections"}, "action": {"name": "can_create"}}], - }, - ) - - assert_401(response) - - def test_should_raise_403_forbidden(self): - response = self.client.post( - "/fab/v1/roles", - json={ - "name": "mytest2", - "actions": [{"resource": {"name": "Connections"}, "action": {"name": "can_create"}}], - }, - environ_overrides={"REMOTE_USER": "test_no_permissions"}, - ) - assert response.status_code == 403 - - @pytest.mark.parametrize( - ("set_auth_role_public", "expected_status_code"), - (("Public", 403), ("Admin", 200)), - indirect=["set_auth_role_public"], - ) - def test_with_auth_role_public_set(self, set_auth_role_public, expected_status_code): - payload = { - "name": "Test2", - "actions": [{"resource": {"name": "Connections"}, "action": {"name": "can_create"}}], - } - response = self.client.post("/fab/v1/roles", json=payload) - assert response.status_code == expected_status_code, response.json - - -class TestDeleteRole(TestRoleEndpoint): - def test_delete_should_respond_204(self, session): - role = create_role(self.app, "mytestrole") - response = self.client.delete(f"/fab/v1/roles/{role.name}", environ_overrides={"REMOTE_USER": "test"}) - assert response.status_code == 204 - role_obj = session.scalars(select(Role).where(Role.name == role.name)).all() - assert len(role_obj) == 0 - - def test_delete_should_respond_404(self): - response = self.client.delete( - "/fab/v1/roles/invalidrolename", environ_overrides={"REMOTE_USER": "test"} - ) - assert response.status_code == 404 - assert response.json == { - "detail": "Role with name 'invalidrolename' was not found", - "status": 404, - "title": "Role not found", - "type": EXCEPTIONS_LINK_MAP[404], - } - - def test_should_raises_401_unauthenticated(self): - response = self.client.delete("/fab/v1/roles/test") - - assert_401(response) - - def test_should_raise_403_forbidden(self): - response = self.client.delete( - "/fab/v1/roles/test", environ_overrides={"REMOTE_USER": "test_no_permissions"} - ) - assert response.status_code == 403 - - @pytest.mark.parametrize( - ("set_auth_role_public", "expected_status_code"), - (("Public", 403), ("Admin", 204)), - indirect=["set_auth_role_public"], - ) - def test_with_auth_role_public_set(self, set_auth_role_public, expected_status_code): - role = create_role(self.app, "mytestrole") - response = self.client.delete(f"/fab/v1/roles/{role.name}") - assert response.status_code == expected_status_code, response.location - - -class TestPatchRole(TestRoleEndpoint): - @pytest.mark.parametrize( - ("payload", "expected_name", "expected_actions"), - [ - ({"name": "mytest"}, "mytest", []), - ( - { - "name": "mytest2", - "actions": [{"resource": {"name": "Connections"}, "action": {"name": "can_create"}}], - }, - "mytest2", - [{"resource": {"name": "Connections"}, "action": {"name": "can_create"}}], - ), - ], - ) - def test_patch_should_respond_200(self, payload, expected_name, expected_actions): - role = create_role(self.app, "mytestrole") - response = self.client.patch( - f"/fab/v1/roles/{role.name}", json=payload, environ_overrides={"REMOTE_USER": "test"} - ) - assert response.status_code == 200 - assert response.json["name"] == expected_name - assert response.json["actions"] == expected_actions - - def test_patch_should_update_correct_roles_permissions(self): - create_role(self.app, "role_to_change") - create_role(self.app, "already_exists") - - response = self.client.patch( - "/fab/v1/roles/role_to_change", - json={ - "name": "already_exists", - "actions": [{"action": {"name": "can_delete"}, "resource": {"name": "XComs"}}], - }, - environ_overrides={"REMOTE_USER": "test"}, - ) - assert response.status_code == 200 - - updated_permissions = self.app.appbuilder.sm.find_role("role_to_change").permissions - assert len(updated_permissions) == 1 - assert updated_permissions[0].resource.name == "XComs" - assert updated_permissions[0].action.name == "can_delete" - - assert len(self.app.appbuilder.sm.find_role("already_exists").permissions) == 0 - - @pytest.mark.parametrize( - ("update_mask", "payload", "expected_name", "expected_actions"), - [ - ( - "?update_mask=name", - { - "name": "mytest2", - "actions": [{"resource": {"name": "Connections"}, "action": {"name": "can_create"}}], - }, - "mytest2", - [], - ), - ( - "?update_mask=name, actions", # both name and actions in update mask - { - "name": "mytest2", - "actions": [{"resource": {"name": "Connections"}, "action": {"name": "can_create"}}], - }, - "mytest2", - [{"resource": {"name": "Connections"}, "action": {"name": "can_create"}}], - ), - ], - ) - def test_patch_should_respond_200_with_update_mask( - self, update_mask, payload, expected_name, expected_actions - ): - role = create_role(self.app, "mytestrole") - assert role.permissions == [] - response = self.client.patch( - f"/fab/v1/roles/{role.name}{update_mask}", - json=payload, - environ_overrides={"REMOTE_USER": "test"}, - ) - assert response.status_code == 200 - assert response.json["name"] == expected_name - assert response.json["actions"] == expected_actions - - def test_patch_should_respond_400_for_invalid_fields_in_update_mask(self): - role = create_role(self.app, "mytestrole") - payload = {"name": "testme"} - response = self.client.patch( - f"/fab/v1/roles/{role.name}?update_mask=invalid_name", - json=payload, - environ_overrides={"REMOTE_USER": "test"}, - ) - assert response.status_code == 400 - assert response.json["detail"] == "'invalid_name' in update_mask is unknown" - - @pytest.mark.parametrize( - ("payload", "expected_error"), - [ - ( - { - "name": "testme", - "permissions": [ # Using permissions instead of actions should raise - {"resource": {"name": "Connections"}, "action": {"name": "can_create"}} - ], - }, - "{'permissions': ['Unknown field.']}", - ), - ( - { - "name": "testme", - "actions": [ - { - "view_menu": {"name": "Connections"}, # Using view_menu instead of resource - "action": {"name": "can_create"}, - } - ], - }, - "{'actions': {0: {'view_menu': ['Unknown field.']}}}", - ), - ( - { - "name": "testme", - "actions": [ - { - "resource": {"name": "FooBars"}, # Using wrong resource name - "action": {"name": "can_create"}, - } - ], - }, - "The specified resource: 'FooBars' was not found", - ), - ( - { - "name": "testme", - "actions": [ - { - "resource": {"name": "Connections"}, # Using wrong action name - "action": {"name": "can_invalid"}, - } - ], - }, - "The specified action: 'can_invalid' was not found", - ), - ], - ) - def test_patch_should_respond_400_for_invalid_update(self, payload, expected_error): - role = create_role(self.app, "mytestrole") - response = self.client.patch( - f"/fab/v1/roles/{role.name}", - json=payload, - environ_overrides={"REMOTE_USER": "test"}, - ) - assert response.status_code == 400 - assert response.json["detail"] == expected_error - - def test_should_raises_401_unauthenticated(self): - response = self.client.patch( - "/fab/v1/roles/test", - json={ - "name": "mytest2", - "actions": [{"resource": {"name": "Connections"}, "action": {"name": "can_create"}}], - }, - ) - - assert_401(response) - - def test_should_raise_403_forbidden(self): - response = self.client.patch( - "/fab/v1/roles/test", - json={ - "name": "mytest2", - "actions": [{"resource": {"name": "Connections"}, "action": {"name": "can_create"}}], - }, - environ_overrides={"REMOTE_USER": "test_no_permissions"}, - ) - assert response.status_code == 403 - - @pytest.mark.parametrize( - ("set_auth_role_public", "expected_status_code"), - (("Public", 403), ("Admin", 200)), - indirect=["set_auth_role_public"], - ) - def test_with_auth_role_public_set(self, set_auth_role_public, expected_status_code): - role = create_role(self.app, "mytestrole") - response = self.client.patch( - f"/fab/v1/roles/{role.name}", - json={"name": "mytest"}, - ) - assert response.status_code == expected_status_code, response.json diff --git a/providers/fab/tests/unit/fab/auth_manager/api_endpoints/test_user_endpoint.py b/providers/fab/tests/unit/fab/auth_manager/api_endpoints/test_user_endpoint.py deleted file mode 100644 index 80bd70547e6c6..0000000000000 --- a/providers/fab/tests/unit/fab/auth_manager/api_endpoints/test_user_endpoint.py +++ /dev/null @@ -1,829 +0,0 @@ -# Licensed to the Apache Software Foundation (ASF) under one -# or more contributor license agreements. See the NOTICE file -# distributed with this work for additional information -# regarding copyright ownership. The ASF licenses this file -# to you under the Apache License, Version 2.0 (the -# "License"); you may not use this file except in compliance -# with the License. You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, -# software distributed under the License is distributed on an -# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY -# KIND, either express or implied. See the License for the -# specific language governing permissions and limitations -# under the License. -from __future__ import annotations - -import unittest.mock - -import pytest -from flask_login import logout_user -from sqlalchemy import delete, func, select - -from airflow.providers.fab.www.api_connexion.exceptions import EXCEPTIONS_LINK_MAP -from airflow.providers.fab.www.security import permissions -from airflow.utils.session import create_session - -from tests_common.test_utils.config import conf_vars -from unit.fab.auth_manager.api_endpoints.api_connexion_utils import ( - assert_401, - create_user, - delete_role, - delete_user, -) - -try: - from airflow.utils import timezone # type: ignore[attr-defined] -except AttributeError: - from airflow.sdk import timezone - - -from airflow.providers.fab.auth_manager.models import User - -pytestmark = pytest.mark.db_test - - -DEFAULT_TIME = "2020-06-11T18:00:00+00:00" - - -@pytest.fixture(scope="module") -def configured_app(minimal_app_for_auth_api): - with conf_vars( - { - ( - "core", - "auth_manager", - ): "airflow.providers.fab.auth_manager.fab_auth_manager.FabAuthManager", - } - ): - with minimal_app_for_auth_api.app_context(): - create_user( - minimal_app_for_auth_api, - username="test", - role_name="Test", - permissions=[ - (permissions.ACTION_CAN_CREATE, permissions.RESOURCE_USER), - (permissions.ACTION_CAN_DELETE, permissions.RESOURCE_USER), - (permissions.ACTION_CAN_EDIT, permissions.RESOURCE_USER), - (permissions.ACTION_CAN_READ, permissions.RESOURCE_USER), - ], - ) - create_user( - minimal_app_for_auth_api, username="test_no_permissions", role_name="TestNoPermissions" - ) - - yield minimal_app_for_auth_api - - delete_user(minimal_app_for_auth_api, username="test") - delete_user(minimal_app_for_auth_api, username="test_no_permissions") - delete_role(minimal_app_for_auth_api, name="TestNoPermissions") - - -class TestUserEndpoint: - @pytest.fixture(autouse=True) - def setup_attrs(self, configured_app, request) -> None: - self.app = configured_app - self.client = self.app.test_client() - self.session = self.app.appbuilder.session - - # Logout the user after each request - @request.addfinalizer - def logout(): - with configured_app.test_request_context(): - logout_user() - - def teardown_method(self) -> None: - # Delete users that have our custom default time - self.session.execute(delete(User).where(User.changed_on == timezone.parse(DEFAULT_TIME))) - self.session.commit() - - def _create_users(self, count, roles=None): - # create users with defined created_on and changed_on date - # for easy testing - if roles is None: - roles = [] - return [ - User( - first_name=f"test{i}", - last_name=f"test{i}", - username=f"TEST_USER{i}", - email=f"mytest@test{i}.org", - roles=roles or [], - created_on=timezone.parse(DEFAULT_TIME), - changed_on=timezone.parse(DEFAULT_TIME), - active=True, - ) - for i in range(1, count + 1) - ] - - -class TestGetUser(TestUserEndpoint): - def test_should_respond_200(self): - users = self._create_users(1) - self.session.add_all(users) - self.session.commit() - response = self.client.get("/fab/v1/users/TEST_USER1", environ_overrides={"REMOTE_USER": "test"}) - assert response.status_code == 200 - assert response.json == { - "active": True, - "changed_on": DEFAULT_TIME, - "created_on": DEFAULT_TIME, - "email": "mytest@test1.org", - "fail_login_count": None, - "first_name": "test1", - "last_login": None, - "last_name": "test1", - "login_count": None, - "roles": [], - "username": "TEST_USER1", - } - - def test_last_names_can_be_empty(self): - prince = User( - first_name="Prince", - last_name="", - username="prince", - email="prince@example.org", - roles=[], - created_on=timezone.parse(DEFAULT_TIME), - changed_on=timezone.parse(DEFAULT_TIME), - ) - self.session.add_all([prince]) - self.session.commit() - response = self.client.get("/fab/v1/users/prince", environ_overrides={"REMOTE_USER": "test"}) - assert response.status_code == 200 - assert response.json == { - "active": True, - "changed_on": DEFAULT_TIME, - "created_on": DEFAULT_TIME, - "email": "prince@example.org", - "fail_login_count": None, - "first_name": "Prince", - "last_login": None, - "last_name": "", - "login_count": None, - "roles": [], - "username": "prince", - } - - def test_first_names_can_be_empty(self): - liberace = User( - first_name="", - last_name="Liberace", - username="liberace", - email="liberace@example.org", - roles=[], - created_on=timezone.parse(DEFAULT_TIME), - changed_on=timezone.parse(DEFAULT_TIME), - ) - self.session.add_all([liberace]) - self.session.commit() - response = self.client.get("/fab/v1/users/liberace", environ_overrides={"REMOTE_USER": "test"}) - assert response.status_code == 200 - assert response.json == { - "active": True, - "changed_on": DEFAULT_TIME, - "created_on": DEFAULT_TIME, - "email": "liberace@example.org", - "fail_login_count": None, - "first_name": "", - "last_login": None, - "last_name": "Liberace", - "login_count": None, - "roles": [], - "username": "liberace", - } - - def test_both_first_and_last_names_can_be_empty(self): - nameless = User( - first_name="", - last_name="", - username="nameless", - email="nameless@example.org", - roles=[], - created_on=timezone.parse(DEFAULT_TIME), - changed_on=timezone.parse(DEFAULT_TIME), - ) - self.session.add_all([nameless]) - self.session.commit() - response = self.client.get("/fab/v1/users/nameless", environ_overrides={"REMOTE_USER": "test"}) - assert response.status_code == 200 - assert response.json == { - "active": True, - "changed_on": DEFAULT_TIME, - "created_on": DEFAULT_TIME, - "email": "nameless@example.org", - "fail_login_count": None, - "first_name": "", - "last_login": None, - "last_name": "", - "login_count": None, - "roles": [], - "username": "nameless", - } - - def test_should_respond_404(self): - response = self.client.get("/fab/v1/users/invalid-user", environ_overrides={"REMOTE_USER": "test"}) - assert response.status_code == 404 - assert response.json == { - "detail": "The User with username `invalid-user` was not found", - "status": 404, - "title": "User not found", - "type": EXCEPTIONS_LINK_MAP[404], - } - - def test_should_raises_401_unauthenticated(self): - response = self.client.get("/fab/v1/users/TEST_USER1") - assert_401(response) - - def test_should_raise_403_forbidden(self): - response = self.client.get( - "/fab/v1/users/TEST_USER1", environ_overrides={"REMOTE_USER": "test_no_permissions"} - ) - assert response.status_code == 403 - - -class TestGetUsers(TestUserEndpoint): - def test_should_response_200(self): - response = self.client.get("/fab/v1/users", environ_overrides={"REMOTE_USER": "test"}) - assert response.status_code == 200 - assert response.json["total_entries"] == 2 - usernames = [user["username"] for user in response.json["users"] if user] - assert usernames == ["test", "test_no_permissions"] - - def test_should_raises_401_unauthenticated(self): - response = self.client.get("/fab/v1/users") - assert_401(response) - - def test_should_raise_403_forbidden(self): - response = self.client.get("/fab/v1/users", environ_overrides={"REMOTE_USER": "test_no_permissions"}) - assert response.status_code == 403 - - -class TestGetUsersPagination(TestUserEndpoint): - @pytest.mark.parametrize( - ("url", "expected_usernames"), - [ - ("/fab/v1/users?limit=1", ["test"]), - ("/fab/v1/users?limit=2", ["test", "test_no_permissions"]), - ( - "/fab/v1/users?offset=5", - [ - "TEST_USER4", - "TEST_USER5", - "TEST_USER6", - "TEST_USER7", - "TEST_USER8", - "TEST_USER9", - "TEST_USER10", - ], - ), - ( - "/fab/v1/users?offset=0", - [ - "test", - "test_no_permissions", - "TEST_USER1", - "TEST_USER2", - "TEST_USER3", - "TEST_USER4", - "TEST_USER5", - "TEST_USER6", - "TEST_USER7", - "TEST_USER8", - "TEST_USER9", - "TEST_USER10", - ], - ), - ("/fab/v1/users?limit=1&offset=5", ["TEST_USER4"]), - ("/fab/v1/users?limit=1&offset=1", ["test_no_permissions"]), - ( - "/fab/v1/users?limit=2&offset=2", - ["TEST_USER1", "TEST_USER2"], - ), - ], - ) - def test_handle_limit_offset(self, url, expected_usernames): - users = self._create_users(10) - self.session.add_all(users) - self.session.commit() - response = self.client.get(url, environ_overrides={"REMOTE_USER": "test"}) - assert response.status_code == 200 - assert response.json["total_entries"] == 12 - usernames = [user["username"] for user in response.json["users"] if user] - assert usernames == expected_usernames - - def test_should_respect_page_size_limit_default(self): - users = self._create_users(200) - self.session.add_all(users) - self.session.commit() - - response = self.client.get("/fab/v1/users", environ_overrides={"REMOTE_USER": "test"}) - assert response.status_code == 200 - # Explicitly add the 2 users on setUp - assert response.json["total_entries"] == 200 + len(["test", "test_no_permissions"]) - assert len(response.json["users"]) == 100 - - def test_should_response_400_with_invalid_order_by(self): - users = self._create_users(2) - self.session.add_all(users) - self.session.commit() - response = self.client.get("/fab/v1/users?order_by=myname", environ_overrides={"REMOTE_USER": "test"}) - assert response.status_code == 400 - msg = "Ordering with 'myname' is disallowed or the attribute does not exist on the model" - assert response.json["detail"] == msg - - def test_limit_of_zero_should_return_default(self): - users = self._create_users(200) - self.session.add_all(users) - self.session.commit() - - response = self.client.get("/fab/v1/users?limit=0", environ_overrides={"REMOTE_USER": "test"}) - assert response.status_code == 200 - # Explicit add the 2 users on setUp - assert response.json["total_entries"] == 200 + len(["test", "test_no_permissions"]) - assert len(response.json["users"]) == 50 - - @conf_vars({("api", "maximum_page_limit"): "150"}) - def test_should_return_conf_max_if_req_max_above_conf(self): - users = self._create_users(200) - self.session.add_all(users) - self.session.commit() - - response = self.client.get("/fab/v1/users?limit=180", environ_overrides={"REMOTE_USER": "test"}) - assert response.status_code == 200 - assert len(response.json["users"]) == 150 - - -EXAMPLE_USER_NAME = "example_user" - -EXAMPLE_USER_EMAIL = "example_user@example.com" - - -def _delete_user(**filters): - with create_session() as session: - user = session.scalars(select(User).filter_by(**filters)).first() - if user is None: - return - user.roles = [] - session.execute(delete(User).filter_by(**filters)) - - -@pytest.fixture -def autoclean_username(): - _delete_user(username=EXAMPLE_USER_NAME) - yield EXAMPLE_USER_NAME - _delete_user(username=EXAMPLE_USER_NAME) - - -@pytest.fixture -def autoclean_email(): - _delete_user(email=EXAMPLE_USER_EMAIL) - yield EXAMPLE_USER_EMAIL - _delete_user(email=EXAMPLE_USER_EMAIL) - - -@pytest.fixture -def user_with_same_username(configured_app, autoclean_username): - user = create_user( - configured_app, - username=autoclean_username, - email="another_user@example.com", - role_name="TestNoPermissions", - ) - assert user, f"failed to create user '{autoclean_username} '" - return user - - -@pytest.fixture -def user_with_same_email(configured_app, autoclean_email): - user = create_user( - configured_app, - username="another_user", - email=autoclean_email, - role_name="TestNoPermissions", - ) - assert user, f"failed to create user 'another_user <{autoclean_email}>'" - return user - - -@pytest.fixture -def user_different(configured_app): - username = "another_user" - email = "another_user@example.com" - - _delete_user(username=username, email=email) - user = create_user(configured_app, username=username, email=email, role_name="TestNoPermissions") - assert user, "failed to create user 'another_user '" - yield user - _delete_user(username=username, email=email) - - -@pytest.fixture -def autoclean_user_payload(autoclean_username, autoclean_email): - return { - "username": autoclean_username, - "password": "resutsop", - "email": autoclean_email, - "first_name": "Tester", - "last_name": "", - } - - -@pytest.fixture -def autoclean_admin_user(configured_app, autoclean_user_payload): - security_manager = configured_app.appbuilder.sm - return security_manager.add_user( - role=security_manager.find_role("Admin"), - **autoclean_user_payload, - ) - - -class TestPostUser(TestUserEndpoint): - def test_with_default_role(self, autoclean_username, autoclean_user_payload): - self.client.application.config["AUTH_USER_REGISTRATION_ROLE"] = "Public" - response = self.client.post( - "/fab/v1/users", - json=autoclean_user_payload, - environ_overrides={"REMOTE_USER": "test"}, - ) - assert response.status_code == 200, response.json - - security_manager = self.app.appbuilder.sm - user = security_manager.find_user(autoclean_username) - assert user is not None - assert user.roles == [security_manager.find_role("Public")] - - def test_with_custom_roles(self, autoclean_username, autoclean_user_payload): - response = self.client.post( - "/fab/v1/users", - json={"roles": [{"name": "User"}, {"name": "Viewer"}], **autoclean_user_payload}, - environ_overrides={"REMOTE_USER": "test"}, - ) - assert response.status_code == 200, response.json - - security_manager = self.app.appbuilder.sm - user = security_manager.find_user(autoclean_username) - assert user is not None - assert {r.name for r in user.roles} == {"User", "Viewer"} - - @pytest.mark.usefixtures("user_different") - def test_with_existing_different_user(self, autoclean_user_payload): - response = self.client.post( - "/fab/v1/users", - json={"roles": [{"name": "User"}, {"name": "Viewer"}], **autoclean_user_payload}, - environ_overrides={"REMOTE_USER": "test"}, - ) - assert response.status_code == 200, response.json - - def test_unauthenticated(self, autoclean_user_payload): - response = self.client.post( - "/fab/v1/users", - json=autoclean_user_payload, - ) - assert response.status_code == 401, response.json - - def test_forbidden(self, autoclean_user_payload): - response = self.client.post( - "/fab/v1/users", - json=autoclean_user_payload, - environ_overrides={"REMOTE_USER": "test_no_permissions"}, - ) - assert response.status_code == 403, response.json - - @pytest.mark.parametrize( - ("existing_user_fixture_name", "error_detail_template"), - [ - ("user_with_same_username", "Username `{username}` already exists. Use PATCH to update."), - ("user_with_same_email", "The email `{email}` is already taken."), - ], - ids=["username", "email"], - ) - def test_already_exists( - self, - request, - autoclean_user_payload, - existing_user_fixture_name, - error_detail_template, - ): - existing = request.getfixturevalue(existing_user_fixture_name) - - response = self.client.post( - "/fab/v1/users", - json=autoclean_user_payload, - environ_overrides={"REMOTE_USER": "test"}, - ) - assert response.status_code == 409, response.json - - error_detail = error_detail_template.format(username=existing.username, email=existing.email) - assert response.json["detail"] == error_detail - - @pytest.mark.parametrize( - ("payload_converter", "error_message"), - [ - pytest.param( - lambda p: {k: v for k, v in p.items() if k != "username"}, - "{'username': ['Missing data for required field.']}", - id="missing-required", - ), - pytest.param( - lambda p: {"i-am": "a typo", **p}, - "{'i-am': ['Unknown field.']}", - id="unknown-user-field", - ), - pytest.param( - lambda p: {**p, "roles": [{"also": "a typo", "name": "User"}]}, - "{'roles': {0: {'also': ['Unknown field.']}}}", - id="unknown-role-field", - ), - pytest.param( - lambda p: {**p, "roles": [{"name": "God"}, {"name": "User"}, {"name": "Overlord"}]}, - "Unknown roles: 'God', 'Overlord'", - id="unknown-role", - ), - ], - ) - def test_invalid_payload(self, autoclean_user_payload, payload_converter, error_message): - response = self.client.post( - "/fab/v1/users", - json=payload_converter(autoclean_user_payload), - environ_overrides={"REMOTE_USER": "test"}, - ) - assert response.status_code == 400, response.json - assert response.json == { - "detail": error_message, - "status": 400, - "title": "Bad Request", - "type": EXCEPTIONS_LINK_MAP[400], - } - - def test_internal_server_error(self, autoclean_user_payload): - with unittest.mock.patch.object(self.app.appbuilder.sm, "add_user", return_value=None): - response = self.client.post( - "/fab/v1/users", - json=autoclean_user_payload, - environ_overrides={"REMOTE_USER": "test"}, - ) - assert response.json == { - "detail": "Failed to add user `example_user`.", - "status": 500, - "title": "Internal Server Error", - "type": EXCEPTIONS_LINK_MAP[500], - } - - -class TestPatchUser(TestUserEndpoint): - @pytest.mark.usefixtures("autoclean_admin_user") - def test_change(self, autoclean_username, autoclean_user_payload): - autoclean_user_payload["first_name"] = "Changed" - response = self.client.patch( - f"/fab/v1/users/{autoclean_username}", - json=autoclean_user_payload, - environ_overrides={"REMOTE_USER": "test"}, - ) - assert response.status_code == 200, response.json - - # The first name is changed. - data = response.json - assert data["first_name"] == "Changed" - assert data["last_name"] == "" - - @pytest.mark.usefixtures("autoclean_admin_user") - def test_change_with_update_mask(self, autoclean_username, autoclean_user_payload): - autoclean_user_payload["first_name"] = "Changed" - autoclean_user_payload["last_name"] = "McTesterson" - response = self.client.patch( - f"/fab/v1/users/{autoclean_username}?update_mask=last_name", - json=autoclean_user_payload, - environ_overrides={"REMOTE_USER": "test"}, - ) - assert response.status_code == 200, response.json - - # The first name is changed, but the last name isn't since we masked it. - data = response.json - assert data["first_name"] == "Tester" - assert data["last_name"] == "McTesterson" - - @pytest.mark.parametrize( - ("payload", "error_message"), - [ - ({"username": "another_user"}, "The username `another_user` already exists"), - ({"email": "another_user@example.com"}, "The email `another_user@example.com` already exists"), - ], - ids=["username", "email"], - ) - @pytest.mark.usefixtures("user_different") - @pytest.mark.usefixtures("autoclean_admin_user") - def test_patch_already_exists( - self, - payload, - error_message, - autoclean_user_payload, - autoclean_username, - ): - autoclean_user_payload.update(payload) - response = self.client.patch( - f"/fab/v1/users/{autoclean_username}", - json=autoclean_user_payload, - environ_overrides={"REMOTE_USER": "test"}, - ) - assert response.status_code == 409, response.json - - assert response.json["detail"] == error_message - - @pytest.mark.parametrize( - "field", - ["username", "first_name", "last_name", "email"], - ) - @pytest.mark.usefixtures("autoclean_admin_user") - def test_required_fields( - self, - field, - autoclean_user_payload, - autoclean_username, - ): - autoclean_user_payload.pop(field) - response = self.client.patch( - f"/fab/v1/users/{autoclean_username}", - json=autoclean_user_payload, - environ_overrides={"REMOTE_USER": "test"}, - ) - assert response.status_code == 400, response.json - assert response.json["detail"] == f"{{'{field}': ['Missing data for required field.']}}" - - @pytest.mark.usefixtures("autoclean_admin_user") - def test_username_can_be_updated(self, autoclean_user_payload, autoclean_username): - testusername = "testusername" - autoclean_user_payload.update({"username": testusername}) - response = self.client.patch( - f"/fab/v1/users/{autoclean_username}", - json=autoclean_user_payload, - environ_overrides={"REMOTE_USER": "test"}, - ) - _delete_user(username=testusername) - assert response.json["username"] == testusername - - @pytest.mark.usefixtures("autoclean_admin_user") - @unittest.mock.patch( - "airflow.providers.fab.auth_manager.api_endpoints.user_endpoint.generate_password_hash", - return_value="fake-hashed-pass", - ) - def test_password_hashed( - self, - mock_generate_password_hash, - autoclean_username, - autoclean_user_payload, - ): - autoclean_user_payload["password"] = "new-pass" - response = self.client.patch( - f"/fab/v1/users/{autoclean_username}", - json=autoclean_user_payload, - environ_overrides={"REMOTE_USER": "test"}, - ) - assert response.status_code == 200, response.json - assert "password" not in response.json - - mock_generate_password_hash.assert_called_once_with("new-pass") - password_in_db = self.session.scalar(select(User.password).where(User.username == autoclean_username)) - assert password_in_db == "fake-hashed-pass" - - @pytest.mark.usefixtures("autoclean_admin_user") - def test_replace_roles(self, autoclean_username, autoclean_user_payload): - # Patching a user's roles should replace the entire list. - autoclean_user_payload["roles"] = [{"name": "User"}, {"name": "Viewer"}] - response = self.client.patch( - f"/fab/v1/users/{autoclean_username}?update_mask=roles", - json=autoclean_user_payload, - environ_overrides={"REMOTE_USER": "test"}, - ) - assert response.status_code == 200, response.json - assert {d["name"] for d in response.json["roles"]} == {"User", "Viewer"} - - @pytest.mark.usefixtures("autoclean_admin_user") - def test_unchanged(self, autoclean_username, autoclean_user_payload): - # Should allow a PATCH that changes nothing. - response = self.client.patch( - f"/fab/v1/users/{autoclean_username}", - json=autoclean_user_payload, - environ_overrides={"REMOTE_USER": "test"}, - ) - assert response.status_code == 200, response.json - - expected = {k: v for k, v in autoclean_user_payload.items() if k != "password"} - assert {k: response.json[k] for k in expected} == expected - - @pytest.mark.usefixtures("autoclean_admin_user") - def test_unauthenticated(self, autoclean_username, autoclean_user_payload): - response = self.client.patch( - f"/fab/v1/users/{autoclean_username}", - json=autoclean_user_payload, - ) - assert response.status_code == 401, response.json - - @pytest.mark.usefixtures("autoclean_admin_user") - def test_forbidden(self, autoclean_username, autoclean_user_payload): - response = self.client.patch( - f"/fab/v1/users/{autoclean_username}", - json=autoclean_user_payload, - environ_overrides={"REMOTE_USER": "test_no_permissions"}, - ) - assert response.status_code == 403, response.json - - def test_not_found(self, autoclean_username, autoclean_user_payload): - # This test does not populate autoclean_admin_user into the database. - response = self.client.patch( - f"/fab/v1/users/{autoclean_username}", - json=autoclean_user_payload, - environ_overrides={"REMOTE_USER": "test"}, - ) - assert response.status_code == 404, response.json - - @pytest.mark.parametrize( - ("payload_converter", "error_message"), - [ - pytest.param( - lambda p: {k: v for k, v in p.items() if k != "username"}, - "{'username': ['Missing data for required field.']}", - id="missing-required", - ), - pytest.param( - lambda p: {"i-am": "a typo", **p}, - "{'i-am': ['Unknown field.']}", - id="unknown-user-field", - ), - pytest.param( - lambda p: {**p, "roles": [{"also": "a typo", "name": "User"}]}, - "{'roles': {0: {'also': ['Unknown field.']}}}", - id="unknown-role-field", - ), - pytest.param( - lambda p: {**p, "roles": [{"name": "God"}, {"name": "User"}, {"name": "Overlord"}]}, - "Unknown roles: 'God', 'Overlord'", - id="unknown-role", - ), - ], - ) - @pytest.mark.usefixtures("autoclean_admin_user") - def test_invalid_payload( - self, - autoclean_username, - autoclean_user_payload, - payload_converter, - error_message, - ): - response = self.client.patch( - f"/fab/v1/users/{autoclean_username}", - json=payload_converter(autoclean_user_payload), - environ_overrides={"REMOTE_USER": "test"}, - ) - assert response.status_code == 400, response.json - assert response.json == { - "detail": error_message, - "status": 400, - "title": "Bad Request", - "type": EXCEPTIONS_LINK_MAP[400], - } - - -class TestDeleteUser(TestUserEndpoint): - @pytest.mark.usefixtures("autoclean_admin_user") - def test_delete(self, autoclean_username): - response = self.client.delete( - f"/fab/v1/users/{autoclean_username}", - environ_overrides={"REMOTE_USER": "test"}, - ) - assert response.status_code == 204, response.json # NO CONTENT. - assert ( - self.session.scalar(select(func.count(User.id)).where(User.username == autoclean_username)) == 0 - ) - - @pytest.mark.usefixtures("autoclean_admin_user") - def test_unauthenticated(self, autoclean_username): - response = self.client.delete( - f"/fab/v1/users/{autoclean_username}", - ) - assert response.status_code == 401, response.json - assert ( - self.session.scalar(select(func.count(User.id)).where(User.username == autoclean_username)) == 1 - ) - - @pytest.mark.usefixtures("autoclean_admin_user") - def test_forbidden(self, autoclean_username): - response = self.client.delete( - f"/fab/v1/users/{autoclean_username}", - environ_overrides={"REMOTE_USER": "test_no_permissions"}, - ) - assert response.status_code == 403, response.json - assert ( - self.session.scalar(select(func.count(User.id)).where(User.username == autoclean_username)) == 1 - ) - - def test_not_found(self, autoclean_username): - # This test does not populate autoclean_admin_user into the database. - response = self.client.delete( - f"/fab/v1/users/{autoclean_username}", - environ_overrides={"REMOTE_USER": "test"}, - ) - assert response.status_code == 404, response.json diff --git a/providers/fab/tests/unit/fab/auth_manager/conftest.py b/providers/fab/tests/unit/fab/auth_manager/conftest.py index 2cad4b4db032c..770999969c020 100644 --- a/providers/fab/tests/unit/fab/auth_manager/conftest.py +++ b/providers/fab/tests/unit/fab/auth_manager/conftest.py @@ -34,9 +34,6 @@ def minimal_app_for_auth_api(): skip_all_except=[ "init_appbuilder", "init_api_auth", - "init_api_auth_provider", - "init_api_connexion", - "init_api_error_handlers", "init_airflow_session_interface", "init_appbuilder_views", ] @@ -44,10 +41,6 @@ def minimal_app_for_auth_api(): def factory(): with conf_vars( { - ( - "fab", - "auth_backends", - ): "unit.fab.auth_manager.api_endpoints.remote_user_api_auth_backend,airflow.providers.fab.auth_manager.api.auth.backend.session", ( "core", "auth_manager", diff --git a/providers/fab/tests/unit/fab/auth_manager/schemas/__init__.py b/providers/fab/tests/unit/fab/auth_manager/schemas/__init__.py deleted file mode 100644 index 217e5db960782..0000000000000 --- a/providers/fab/tests/unit/fab/auth_manager/schemas/__init__.py +++ /dev/null @@ -1,17 +0,0 @@ -# -# Licensed to the Apache Software Foundation (ASF) under one -# or more contributor license agreements. See the NOTICE file -# distributed with this work for additional information -# regarding copyright ownership. The ASF licenses this file -# to you under the Apache License, Version 2.0 (the -# "License"); you may not use this file except in compliance -# with the License. You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, -# software distributed under the License is distributed on an -# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY -# KIND, either express or implied. See the License for the -# specific language governing permissions and limitations -# under the License. diff --git a/providers/fab/tests/unit/fab/auth_manager/schemas/test_role_and_permission_schema.py b/providers/fab/tests/unit/fab/auth_manager/schemas/test_role_and_permission_schema.py deleted file mode 100644 index 8effd552c5b7f..0000000000000 --- a/providers/fab/tests/unit/fab/auth_manager/schemas/test_role_and_permission_schema.py +++ /dev/null @@ -1,105 +0,0 @@ -# Licensed to the Apache Software Foundation (ASF) under one -# or more contributor license agreements. See the NOTICE file -# distributed with this work for additional information -# regarding copyright ownership. The ASF licenses this file -# to you under the Apache License, Version 2.0 (the -# "License"); you may not use this file except in compliance -# with the License. You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, -# software distributed under the License is distributed on an -# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY -# KIND, either express or implied. See the License for the -# specific language governing permissions and limitations -# under the License. -from __future__ import annotations - -import pytest - -from airflow.providers.fab.auth_manager.schemas.role_and_permission_schema import ( - RoleCollection, - role_collection_schema, - role_schema, -) -from airflow.providers.fab.www.security import permissions - -from unit.fab.auth_manager.api_endpoints.api_connexion_utils import create_role, delete_role - -pytestmark = pytest.mark.db_test - - -class TestRoleCollectionItemSchema: - @pytest.fixture(scope="class") - def role(self, minimal_app_for_auth_api): - with minimal_app_for_auth_api.app_context(): - yield create_role( - minimal_app_for_auth_api, - name="Test", - permissions=[ - (permissions.ACTION_CAN_CREATE, permissions.RESOURCE_CONNECTION), - ], - ) - delete_role(minimal_app_for_auth_api, "Test") - - @pytest.fixture(autouse=True) - def _set_attrs(self, minimal_app_for_auth_api, role): - self.app = minimal_app_for_auth_api - self.role = role - - def test_serialize(self): - deserialized_role = role_schema.dump(self.role) - assert deserialized_role == { - "name": "Test", - "actions": [{"resource": {"name": "Connections"}, "action": {"name": "can_create"}}], - } - - def test_deserialize(self): - role = { - "name": "Test", - "actions": [{"resource": {"name": "Connections"}, "action": {"name": "can_create"}}], - } - role_obj = role_schema.load(role) - assert role_obj == { - "name": "Test", - "permissions": [{"resource": {"name": "Connections"}, "action": {"name": "can_create"}}], - } - - -class TestRoleCollectionSchema: - def test_serialize(self, minimal_app_for_auth_api): - with minimal_app_for_auth_api.app_context(): - role1 = create_role( - minimal_app_for_auth_api, - name="Test1", - permissions=[ - (permissions.ACTION_CAN_CREATE, permissions.RESOURCE_CONNECTION), - ], - ) - role2 = create_role( - minimal_app_for_auth_api, - name="Test2", - permissions=[ - (permissions.ACTION_CAN_EDIT, permissions.RESOURCE_DAG), - ], - ) - - instance = RoleCollection([role1, role2], total_entries=2) - deserialized = role_collection_schema.dump(instance) - assert deserialized == { - "roles": [ - { - "name": "Test1", - "actions": [{"resource": {"name": "Connections"}, "action": {"name": "can_create"}}], - }, - { - "name": "Test2", - "actions": [{"resource": {"name": "DAGs"}, "action": {"name": "can_edit"}}], - }, - ], - "total_entries": 2, - } - - delete_role(minimal_app_for_auth_api, "Test1") - delete_role(minimal_app_for_auth_api, "Test2") diff --git a/providers/fab/tests/unit/fab/auth_manager/schemas/test_user_schema.py b/providers/fab/tests/unit/fab/auth_manager/schemas/test_user_schema.py deleted file mode 100644 index b93491dd81081..0000000000000 --- a/providers/fab/tests/unit/fab/auth_manager/schemas/test_user_schema.py +++ /dev/null @@ -1,152 +0,0 @@ -# Licensed to the Apache Software Foundation (ASF) under one -# or more contributor license agreements. See the NOTICE file -# distributed with this work for additional information -# regarding copyright ownership. The ASF licenses this file -# to you under the Apache License, Version 2.0 (the -# "License"); you may not use this file except in compliance -# with the License. You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, -# software distributed under the License is distributed on an -# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY -# KIND, either express or implied. See the License for the -# specific language governing permissions and limitations -# under the License. -from __future__ import annotations - -import pytest -from sqlalchemy import select - -from airflow.providers.fab.auth_manager.models import User -from airflow.providers.fab.auth_manager.schemas.user_schema import ( - user_collection_item_schema, - user_schema, -) - -from tests_common.test_utils.version_compat import AIRFLOW_V_3_1_PLUS - -if AIRFLOW_V_3_1_PLUS: - from airflow._shared.timezones import timezone -else: - from airflow.utils import timezone # type: ignore[attr-defined,no-redef] - -from unit.fab.auth_manager.api_endpoints.api_connexion_utils import create_role, delete_role - -TEST_EMAIL = "test@example.org" - -DEFAULT_TIME = "2021-01-09T13:59:56.336000+00:00" - -pytestmark = pytest.mark.db_test - - -@pytest.fixture(scope="module") -def configured_app(minimal_app_for_auth_api): - app = minimal_app_for_auth_api - with minimal_app_for_auth_api.app_context(): - create_role( - app, - name="TestRole", - permissions=[], - ) - yield app - - delete_role(app, "TestRole") - - -class TestUserBase: - @pytest.fixture(autouse=True) - def setup_attrs(self, configured_app) -> None: - self.app = configured_app - self.client = self.app.test_client() - self.role = self.app.appbuilder.sm.find_role("TestRole") - self.session = self.app.appbuilder.session - - def teardown_method(self): - user = self.session.scalars(select(User).where(User.email == TEST_EMAIL)).first() - if user: - self.session.delete(user) - self.session.commit() - - -class TestUserCollectionItemSchema(TestUserBase): - def test_serialize(self): - user_model = User( - first_name="Foo", - last_name="Bar", - username="test", - password="test", - email=TEST_EMAIL, - created_on=timezone.parse(DEFAULT_TIME), - changed_on=timezone.parse(DEFAULT_TIME), - ) - self.session.add(user_model) - user_model.roles = [self.role] - self.session.commit() - user = self.session.scalars(select(User).where(User.email == TEST_EMAIL)).first() - deserialized_user = user_collection_item_schema.dump(user) - # No user_id and password in dump - assert deserialized_user == { - "created_on": DEFAULT_TIME, - "email": "test@example.org", - "changed_on": DEFAULT_TIME, - "active": True, - "last_login": None, - "last_name": "Bar", - "fail_login_count": None, - "first_name": "Foo", - "username": "test", - "login_count": None, - "roles": [{"name": "TestRole"}], - } - - -class TestUserSchema(TestUserBase): - def test_serialize(self): - user_model = User( - first_name="Foo", - last_name="Bar", - username="test", - password="test", - email=TEST_EMAIL, - created_on=timezone.parse(DEFAULT_TIME), - changed_on=timezone.parse(DEFAULT_TIME), - ) - self.session.add(user_model) - self.session.commit() - user = self.session.scalars(select(User).where(User.email == TEST_EMAIL)).first() - deserialized_user = user_schema.dump(user) - # No user_id and password in dump - assert deserialized_user == { - "roles": [], - "created_on": DEFAULT_TIME, - "email": "test@example.org", - "changed_on": DEFAULT_TIME, - "active": True, - "last_login": None, - "last_name": "Bar", - "fail_login_count": None, - "first_name": "Foo", - "username": "test", - "login_count": None, - } - - def test_deserialize_user(self): - user_dump = { - "roles": [{"name": "TestRole"}], - "email": "test@example.org", - "last_name": "Bar", - "first_name": "Foo", - "username": "test", - "password": "test", # loads password - } - result = user_schema.load(user_dump) - assert result == { - "roles": [{"name": "TestRole"}], - "email": "test@example.org", - "last_name": "Bar", - "first_name": "Foo", - "username": "test", - "password": "test", # Password loaded - } diff --git a/providers/fab/tests/unit/fab/auth_manager/test_fab_auth_manager.py b/providers/fab/tests/unit/fab/auth_manager/test_fab_auth_manager.py index 7cb0964588b64..3b3ebd4bbbe3e 100644 --- a/providers/fab/tests/unit/fab/auth_manager/test_fab_auth_manager.py +++ b/providers/fab/tests/unit/fab/auth_manager/test_fab_auth_manager.py @@ -38,7 +38,7 @@ from tests_common.test_utils.asserts import assert_queries_count from tests_common.test_utils.config import conf_vars -from unit.fab.auth_manager.api_endpoints.api_connexion_utils import create_user, delete_user +from unit.fab.auth_manager.test_utils import create_user, delete_user with suppress(ImportError): from airflow.api_fastapi.auth.managers.models.resource_details import ( diff --git a/providers/fab/tests/unit/fab/auth_manager/test_security.py b/providers/fab/tests/unit/fab/auth_manager/test_security.py index 350101b832a70..0e6cbe8885447 100644 --- a/providers/fab/tests/unit/fab/auth_manager/test_security.py +++ b/providers/fab/tests/unit/fab/auth_manager/test_security.py @@ -52,7 +52,7 @@ from tests_common.test_utils.db import clear_db_dag_bundles, clear_db_dags, clear_db_runs from tests_common.test_utils.permissions import _resource_name from tests_common.test_utils.version_compat import AIRFLOW_V_3_1_PLUS -from unit.fab.auth_manager.api_endpoints.api_connexion_utils import ( +from unit.fab.auth_manager.test_utils import ( create_user, create_user_scope, delete_role, diff --git a/providers/fab/tests/unit/fab/auth_manager/api_endpoints/api_connexion_utils.py b/providers/fab/tests/unit/fab/auth_manager/test_utils.py similarity index 79% rename from providers/fab/tests/unit/fab/auth_manager/api_endpoints/api_connexion_utils.py rename to providers/fab/tests/unit/fab/auth_manager/test_utils.py index 1cd9c628cf572..ae1b582e1b410 100644 --- a/providers/fab/tests/unit/fab/auth_manager/api_endpoints/api_connexion_utils.py +++ b/providers/fab/tests/unit/fab/auth_manager/test_utils.py @@ -19,19 +19,6 @@ from contextlib import contextmanager from airflow.providers.fab.auth_manager.security_manager.override import EXISTING_ROLES -from airflow.providers.fab.www.api_connexion.exceptions import EXCEPTIONS_LINK_MAP - - -@contextmanager -def create_test_client(app, user_name, role_name, permissions): - """ - Helper function to create a client with a temporary user which will be deleted once done - """ - client = app.test_client() - with create_user_scope(app, username=user_name, role_name=role_name, permissions=permissions) as _: - resp = client.post("/login/", data={"username": user_name, "password": user_name}) - assert resp.status_code == 302 - yield client @contextmanager @@ -111,13 +98,3 @@ def delete_user(app, username): ] appbuilder.sm.del_register_user(user) break - - -def assert_401(response): - assert response.status_code == 401, f"Current code: {response.status_code}" - assert response.json == { - "detail": None, - "status": 401, - "title": "Unauthorized", - "type": EXCEPTIONS_LINK_MAP[401], - } diff --git a/providers/fab/tests/unit/fab/auth_manager/views/test_permissions.py b/providers/fab/tests/unit/fab/auth_manager/views/test_permissions.py index fc1af4dfb3a49..8cc8eb00e8023 100644 --- a/providers/fab/tests/unit/fab/auth_manager/views/test_permissions.py +++ b/providers/fab/tests/unit/fab/auth_manager/views/test_permissions.py @@ -23,7 +23,7 @@ from airflow.providers.fab.www.security import permissions from tests_common.test_utils.config import conf_vars -from unit.fab.auth_manager.api_endpoints.api_connexion_utils import create_user, delete_user +from unit.fab.auth_manager.test_utils import create_user, delete_user from unit.fab.auth_manager.views import _assert_dataset_deprecation_warning from unit.fab.utils import client_with_login diff --git a/providers/fab/tests/unit/fab/auth_manager/views/test_roles_list.py b/providers/fab/tests/unit/fab/auth_manager/views/test_roles_list.py index 66192f919adfc..77038fd99391f 100644 --- a/providers/fab/tests/unit/fab/auth_manager/views/test_roles_list.py +++ b/providers/fab/tests/unit/fab/auth_manager/views/test_roles_list.py @@ -23,7 +23,7 @@ from airflow.providers.fab.www.security import permissions from tests_common.test_utils.config import conf_vars -from unit.fab.auth_manager.api_endpoints.api_connexion_utils import create_user, delete_user +from unit.fab.auth_manager.test_utils import create_user, delete_user from unit.fab.auth_manager.views import _assert_dataset_deprecation_warning from unit.fab.utils import client_with_login diff --git a/providers/fab/tests/unit/fab/auth_manager/views/test_user.py b/providers/fab/tests/unit/fab/auth_manager/views/test_user.py index 1ae942824c72d..c2f024cd69aea 100644 --- a/providers/fab/tests/unit/fab/auth_manager/views/test_user.py +++ b/providers/fab/tests/unit/fab/auth_manager/views/test_user.py @@ -23,7 +23,7 @@ from airflow.providers.fab.www.security import permissions from tests_common.test_utils.config import conf_vars -from unit.fab.auth_manager.api_endpoints.api_connexion_utils import create_user, delete_user +from unit.fab.auth_manager.test_utils import create_user, delete_user from unit.fab.auth_manager.views import _assert_dataset_deprecation_warning from unit.fab.utils import client_with_login diff --git a/providers/fab/tests/unit/fab/auth_manager/views/test_user_edit.py b/providers/fab/tests/unit/fab/auth_manager/views/test_user_edit.py index 926753a04c2f0..75bd0727191f9 100644 --- a/providers/fab/tests/unit/fab/auth_manager/views/test_user_edit.py +++ b/providers/fab/tests/unit/fab/auth_manager/views/test_user_edit.py @@ -23,7 +23,7 @@ from airflow.providers.fab.www.security import permissions from tests_common.test_utils.config import conf_vars -from unit.fab.auth_manager.api_endpoints.api_connexion_utils import create_user, delete_user +from unit.fab.auth_manager.test_utils import create_user, delete_user from unit.fab.auth_manager.views import _assert_dataset_deprecation_warning from unit.fab.utils import client_with_login diff --git a/providers/fab/tests/unit/fab/auth_manager/views/test_user_stats.py b/providers/fab/tests/unit/fab/auth_manager/views/test_user_stats.py index 9a68e9bc8fe15..c70c8f3153e2c 100644 --- a/providers/fab/tests/unit/fab/auth_manager/views/test_user_stats.py +++ b/providers/fab/tests/unit/fab/auth_manager/views/test_user_stats.py @@ -23,7 +23,7 @@ from airflow.providers.fab.www.security import permissions from tests_common.test_utils.config import conf_vars -from unit.fab.auth_manager.api_endpoints.api_connexion_utils import create_user, delete_user +from unit.fab.auth_manager.test_utils import create_user, delete_user from unit.fab.auth_manager.views import _assert_dataset_deprecation_warning from unit.fab.utils import client_with_login diff --git a/providers/fab/tests/unit/fab/decorators.py b/providers/fab/tests/unit/fab/decorators.py index 77c9b077e7740..64d9a762401b5 100644 --- a/providers/fab/tests/unit/fab/decorators.py +++ b/providers/fab/tests/unit/fab/decorators.py @@ -34,8 +34,6 @@ def no_op(*args, **kwargs): "init_api_auth", "init_plugins", "init_error_handlers", - "init_api_auth_provider", - "init_api_error_handlers", "init_jinja_globals", "init_airflow_session_interface", "init_appbuilder", diff --git a/providers/fab/tests/unit/fab/www/views/conftest.py b/providers/fab/tests/unit/fab/www/views/conftest.py index d60d126bc5aa0..94f7bbc776155 100644 --- a/providers/fab/tests/unit/fab/www/views/conftest.py +++ b/providers/fab/tests/unit/fab/www/views/conftest.py @@ -27,7 +27,7 @@ from tests_common.test_utils.config import conf_vars from tests_common.test_utils.db import parse_and_sync_to_db -from unit.fab.auth_manager.api_endpoints.api_connexion_utils import delete_user +from unit.fab.auth_manager.test_utils import delete_user from unit.fab.decorators import dont_initialize_flask_app_submodules from unit.fab.utils import client_with_login @@ -49,11 +49,8 @@ def examples_dag_bag(session): def app(examples_dag_bag): @dont_initialize_flask_app_submodules( skip_all_except=[ - "init_api_connexion", "init_appbuilder", - "init_appbuilder_links", "init_appbuilder_views", - "init_flash_views", "init_jinja_globals", "init_plugins", "init_airflow_session_interface", diff --git a/providers/fab/tests/unit/fab/www/views/test_views_custom_user_views.py b/providers/fab/tests/unit/fab/www/views/test_views_custom_user_views.py index 2cfcb3f35fb02..524633ea906c7 100644 --- a/providers/fab/tests/unit/fab/www/views/test_views_custom_user_views.py +++ b/providers/fab/tests/unit/fab/www/views/test_views_custom_user_views.py @@ -29,7 +29,7 @@ from airflow.providers.fab.www.security import permissions from tests_common.test_utils.config import conf_vars -from unit.fab.auth_manager.api_endpoints.api_connexion_utils import ( +from unit.fab.auth_manager.test_utils import ( create_user, delete_role, delete_user, From fcd582772493b1d5ee328b218ec18b3f304ac103 Mon Sep 17 00:00:00 2001 From: Ninad Kale Date: Tue, 3 Mar 2026 09:13:53 +0530 Subject: [PATCH 2/7] =?UTF-8?q?Fix=20CI=20failures=20after=20connexion?= =?UTF-8?q?=E2=86=92FastAPI=20migration=20in=20FAB=20provider?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous commit removed the connexion-based Flask API blueprint (including /fab/v1/users) and deleted the openapi/ package, but two stale references were left behind: 1. Google provider OpenID auth tests hit /fab/v1/users on the Flask test client, which no longer has that route registered. All 8 tests got 404 instead of 401. Fix: register a dummy /fab/v1/users route protected by requires_authentication in the test fixture so the auth backend logic is exercised without depending on the removed endpoint. 2. devel-common/src/docs/provider_conf.py imported the deleted airflow.providers.fab.auth_manager.openapi package at Sphinx init time, crashing both docs build jobs. Fix: remove the import, the fab_auth_manager_flask_api_path variable, and the "Fab auth manager API" redoc entry that referenced the deleted v1-flask-api.yaml spec. Also remove the orphaned fab-public-api-ref.rst stub and its toctree and cross-reference entries in the FAB provider docs. Co-Authored-By: Claude Opus 4.6 --- devel-common/src/docs/provider_conf.py | 12 ---------- .../fab/docs/api-ref/fab-public-api-ref.rst | 23 ------------------- .../docs/auth-manager/api-authentication.rst | 2 +- providers/fab/docs/index.rst | 1 - .../common/auth_backend/test_google_openid.py | 13 +++++++++++ 5 files changed, 14 insertions(+), 37 deletions(-) delete mode 100644 providers/fab/docs/api-ref/fab-public-api-ref.rst diff --git a/devel-common/src/docs/provider_conf.py b/devel-common/src/docs/provider_conf.py index 39af2389ff924..1b979c6c70533 100644 --- a/devel-common/src/docs/provider_conf.py +++ b/devel-common/src/docs/provider_conf.py @@ -361,14 +361,10 @@ from airflow.providers.fab.auth_manager.api_fastapi.openapi import ( __file__ as fab_auth_manager_fastapi_api_file, ) - from airflow.providers.fab.auth_manager.openapi import __file__ as fab_auth_manager_flask_api_file from airflow.providers.keycloak.auth_manager.openapi import ( __file__ as keycloak_auth_manager_fastapi_api_file, ) - fab_auth_manager_flask_api_path = Path(fab_auth_manager_flask_api_file).parent.joinpath( - "v1-flask-api.yaml" - ) fab_auth_manager_fastapi_api_path = Path(fab_auth_manager_fastapi_api_file).parent.joinpath( "v2-fab-auth-manager-generated.yaml" ) @@ -376,14 +372,6 @@ "v2-keycloak-auth-manager-generated.yaml" ) redoc = [ - { - "name": "Fab auth manager API", - "page": "api-ref/fab-public-api-ref", - "spec": fab_auth_manager_flask_api_path.as_posix(), - "opts": { - "hide-hostname": True, - }, - }, { "name": "Fab auth manager token API", "page": "api-ref/fab-token-api-ref", diff --git a/providers/fab/docs/api-ref/fab-public-api-ref.rst b/providers/fab/docs/api-ref/fab-public-api-ref.rst deleted file mode 100644 index 49f389e7d932d..0000000000000 --- a/providers/fab/docs/api-ref/fab-public-api-ref.rst +++ /dev/null @@ -1,23 +0,0 @@ - - .. Licensed to the Apache Software Foundation (ASF) under one - or more contributor license agreements. See the NOTICE file - distributed with this work for additional information - regarding copyright ownership. The ASF licenses this file - to you under the Apache License, Version 2.0 (the - "License"); you may not use this file except in compliance - with the License. You may obtain a copy of the License at - - .. http://www.apache.org/licenses/LICENSE-2.0 - - .. Unless required by applicable law or agreed to in writing, - software distributed under the License is distributed on an - "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - KIND, either express or implied. See the License for the - specific language governing permissions and limitations - under the License. - -FAB auth manager API -==================== - -It's a stub file. It will be converted automatically during the build process -to the valid documentation by the Sphinx plugin. See: /docs/conf.py diff --git a/providers/fab/docs/auth-manager/api-authentication.rst b/providers/fab/docs/auth-manager/api-authentication.rst index b8f5a24de8454..9b5d6fc72fad3 100644 --- a/providers/fab/docs/auth-manager/api-authentication.rst +++ b/providers/fab/docs/auth-manager/api-authentication.rst @@ -19,7 +19,7 @@ FAB auth manager API authentication =================================== .. note:: - This guide only applies to :doc:`FAB auth manager API `. + This guide only applies to the FAB auth manager API. Authentication for the APIs is handled by what is called an authentication backend. The default is to check the user session: diff --git a/providers/fab/docs/index.rst b/providers/fab/docs/index.rst index 2f461706c5395..c3c8010b63313 100644 --- a/providers/fab/docs/index.rst +++ b/providers/fab/docs/index.rst @@ -47,7 +47,6 @@ :hidden: :caption: References - Fab auth manager API Fab auth manager token API .. toctree:: diff --git a/providers/google/tests/unit/google/common/auth_backend/test_google_openid.py b/providers/google/tests/unit/google/common/auth_backend/test_google_openid.py index fc91814441bb2..4f168574ae949 100644 --- a/providers/google/tests/unit/google/common/auth_backend/test_google_openid.py +++ b/providers/google/tests/unit/google/common/auth_backend/test_google_openid.py @@ -52,10 +52,23 @@ def factory(): ): "airflow.providers.fab.auth_manager.fab_auth_manager.FabAuthManager", } ): + from flask import Response + from airflow.providers.fab.www.app import create_app + from airflow.providers.google.common.auth_backend.google_openid import requires_authentication _app = create_app(enable_plugins=False) _app.config["AUTH_ROLE_PUBLIC"] = None + + # Register a dummy route protected by the Google OpenID auth backend. + # The connexion-based /fab/v1/users endpoint was removed when FAB migrated + # to FastAPI, but these tests only need any route guarded by + # requires_authentication to verify the auth backend logic. + @_app.route("/fab/v1/users") + @requires_authentication + def _test_dummy_endpoint(): + return Response("OK", status=200) + return _app return factory() From 38211b84b4881477d90fb9d8e68c9086dd6db28b Mon Sep 17 00:00:00 2001 From: Ninad Kale Date: Tue, 3 Mar 2026 22:01:31 +0530 Subject: [PATCH 3/7] google_openid: return 401 for missing/invalid token --- .../providers/google/common/auth_backend/google_openid.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/providers/google/src/airflow/providers/google/common/auth_backend/google_openid.py b/providers/google/src/airflow/providers/google/common/auth_backend/google_openid.py index 007bff5214dac..fa49e9fec1fe2 100644 --- a/providers/google/src/airflow/providers/google/common/auth_backend/google_openid.py +++ b/providers/google/src/airflow/providers/google/common/auth_backend/google_openid.py @@ -135,12 +135,12 @@ def decorated(*args, **kwargs): access_token = _get_id_token_from_request(flask_request) if not access_token: log.debug("Missing ID Token") - return Response("Forbidden", 403) + return Response("Unauthorized", 401) userid = _verify_id_token(access_token) if not userid: log.debug("Invalid ID Token") - return Response("Forbidden", 403) + return Response("Unauthorized", 401) log.debug("Looking for user with e-mail: %s", userid) From b739e19215fba6b6da0df510e85792c16735c5ab Mon Sep 17 00:00:00 2001 From: Ninad Kale Date: Wed, 4 Mar 2026 00:20:39 +0530 Subject: [PATCH 4/7] Fix werkzeug __version__ compatibility and correct OpenID 403 expectation --- providers/fab/tests/conftest.py | 10 ++++++++++ .../google/common/auth_backend/test_google_openid.py | 2 +- 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/providers/fab/tests/conftest.py b/providers/fab/tests/conftest.py index f56ccce0a3f69..cda3e823ba4c0 100644 --- a/providers/fab/tests/conftest.py +++ b/providers/fab/tests/conftest.py @@ -16,4 +16,14 @@ # under the License. from __future__ import annotations +import importlib.metadata + +import werkzeug + pytest_plugins = "tests_common.pytest_plugin" + +# Flask's test client in older Flask branches reads the Werkzeug version attribute to build +# the default HTTP_USER_AGENT. Werkzeug 3 removed that attribute. +if not hasattr(werkzeug, "__version__"): + werkzeug_version = importlib.metadata.version("werkzeug") + setattr(werkzeug, "__version__", werkzeug_version) diff --git a/providers/google/tests/unit/google/common/auth_backend/test_google_openid.py b/providers/google/tests/unit/google/common/auth_backend/test_google_openid.py index 4f168574ae949..e5921375de497 100644 --- a/providers/google/tests/unit/google/common/auth_backend/test_google_openid.py +++ b/providers/google/tests/unit/google/common/auth_backend/test_google_openid.py @@ -162,7 +162,7 @@ def test_user_not_exists(self, mock_verify_token): with self.app.test_client() as test_client: response = test_client.get("/fab/v1/users", headers={"Authorization": "bearer JWT_TOKEN"}) - assert response.status_code == 401 + assert response.status_code == 403 @conf_vars({("fab", "auth_backends"): "airflow.providers.google.common.auth_backend.google_openid"}) def test_missing_id_token(self): From df3731e092735786c59bdca9f11fa4e22a540107 Mon Sep 17 00:00:00 2001 From: Ninad Kale Date: Wed, 4 Mar 2026 07:26:09 +0530 Subject: [PATCH 5/7] Fix werkzeug.__version__ AttributeError in Google provider tests after connexion removal --- providers/google/tests/conftest.py | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/providers/google/tests/conftest.py b/providers/google/tests/conftest.py index f56ccce0a3f69..67b5123b6a95b 100644 --- a/providers/google/tests/conftest.py +++ b/providers/google/tests/conftest.py @@ -1,3 +1,5 @@ +# providers/google/tests/conftest.py + # Licensed to the Apache Software Foundation (ASF) under one # or more contributor license agreements. See the NOTICE file # distributed with this work for additional information @@ -16,4 +18,13 @@ # under the License. from __future__ import annotations +import importlib.metadata + +import werkzeug + pytest_plugins = "tests_common.pytest_plugin" + +# Flask 2.2.x test client reads werkzeug.__version__ which Werkzeug 3.x removed. +# Connexion 2.x used to pin Werkzeug<3, but connexion is now removed from FAB provider. +if not hasattr(werkzeug, "__version__"): + werkzeug.__version__ = importlib.metadata.version("werkzeug") \ No newline at end of file From bb1a958d1f1be43d44022b428870b0d30d5fc4b1 Mon Sep 17 00:00:00 2001 From: Ninad Kale Date: Wed, 4 Mar 2026 07:36:57 +0530 Subject: [PATCH 6/7] Address vincbeck review comments: rename FAB API ref, remove unused constants module --- devel-common/src/docs/provider_conf.py | 4 ++-- ...{fab-token-api-ref.rst => fab-api-ref.rst} | 0 .../docs/auth-manager/api-authentication.rst | 2 +- providers/fab/docs/auth-manager/token.rst | 2 +- providers/fab/docs/index.rst | 2 +- .../airflow/providers/fab/www/constants.py | 21 ------------------- 6 files changed, 5 insertions(+), 26 deletions(-) rename providers/fab/docs/api-ref/{fab-token-api-ref.rst => fab-api-ref.rst} (100%) delete mode 100644 providers/fab/src/airflow/providers/fab/www/constants.py diff --git a/devel-common/src/docs/provider_conf.py b/devel-common/src/docs/provider_conf.py index 1b979c6c70533..86eedde5a2715 100644 --- a/devel-common/src/docs/provider_conf.py +++ b/devel-common/src/docs/provider_conf.py @@ -373,8 +373,8 @@ ) redoc = [ { - "name": "Fab auth manager token API", - "page": "api-ref/fab-token-api-ref", + "name": "Fab auth manager API", + "page": "api-ref/fab-api-ref", "spec": fab_auth_manager_fastapi_api_path.as_posix(), "opts": { "hide-hostname": True, diff --git a/providers/fab/docs/api-ref/fab-token-api-ref.rst b/providers/fab/docs/api-ref/fab-api-ref.rst similarity index 100% rename from providers/fab/docs/api-ref/fab-token-api-ref.rst rename to providers/fab/docs/api-ref/fab-api-ref.rst diff --git a/providers/fab/docs/auth-manager/api-authentication.rst b/providers/fab/docs/auth-manager/api-authentication.rst index 9b5d6fc72fad3..d06c0e8e49b71 100644 --- a/providers/fab/docs/auth-manager/api-authentication.rst +++ b/providers/fab/docs/auth-manager/api-authentication.rst @@ -19,7 +19,7 @@ FAB auth manager API authentication =================================== .. note:: - This guide only applies to the FAB auth manager API. + This guide only applies to :doc:`FAB auth manager API `. Authentication for the APIs is handled by what is called an authentication backend. The default is to check the user session: diff --git a/providers/fab/docs/auth-manager/token.rst b/providers/fab/docs/auth-manager/token.rst index 0377d030ee691..80afe990227fc 100644 --- a/providers/fab/docs/auth-manager/token.rst +++ b/providers/fab/docs/auth-manager/token.rst @@ -26,7 +26,7 @@ authentication. Once you have the token, include it in the ``Authorization`` header when making requests to the public API. You can generate a JWT token using the ``Create Token`` API endpoint, -documented in :doc:`/api-ref/fab-token-api-ref`. +documented in :doc:`/api-ref/fab-api-ref`. Example ''''''' diff --git a/providers/fab/docs/index.rst b/providers/fab/docs/index.rst index c3c8010b63313..d725da5696cf1 100644 --- a/providers/fab/docs/index.rst +++ b/providers/fab/docs/index.rst @@ -47,7 +47,7 @@ :hidden: :caption: References - Fab auth manager token API + Fab auth manager API .. toctree:: :hidden: diff --git a/providers/fab/src/airflow/providers/fab/www/constants.py b/providers/fab/src/airflow/providers/fab/www/constants.py deleted file mode 100644 index e12a32bcabb82..0000000000000 --- a/providers/fab/src/airflow/providers/fab/www/constants.py +++ /dev/null @@ -1,21 +0,0 @@ -# Licensed to the Apache Software Foundation (ASF) under one -# or more contributor license agreements. See the NOTICE file -# distributed with this work for additional information -# regarding copyright ownership. The ASF licenses this file -# to you under the Apache License, Version 2.0 (the -# "License"); you may not use this file except in compliance -# with the License. You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, -# software distributed under the License is distributed on an -# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY -# KIND, either express or implied. See the License for the -# specific language governing permissions and limitations -# under the License. -from __future__ import annotations - -from pathlib import Path - -WWW = Path(__file__).resolve().parent From 17c7ca01ae6f41f747d022de69b0f200a5f2dbef Mon Sep 17 00:00:00 2001 From: Ninad Kale Date: Fri, 6 Mar 2026 11:48:19 +0530 Subject: [PATCH 7/7] Fix missing newline in google test conftest --- providers/google/tests/conftest.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/providers/google/tests/conftest.py b/providers/google/tests/conftest.py index 67b5123b6a95b..1cae3e67b641f 100644 --- a/providers/google/tests/conftest.py +++ b/providers/google/tests/conftest.py @@ -27,4 +27,4 @@ # Flask 2.2.x test client reads werkzeug.__version__ which Werkzeug 3.x removed. # Connexion 2.x used to pin Werkzeug<3, but connexion is now removed from FAB provider. if not hasattr(werkzeug, "__version__"): - werkzeug.__version__ = importlib.metadata.version("werkzeug") \ No newline at end of file + werkzeug.__version__ = importlib.metadata.version("werkzeug")