diff --git a/devel-common/src/docs/provider_conf.py b/devel-common/src/docs/provider_conf.py index 39af2389ff924..86eedde5a2715 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" ) @@ -378,15 +374,7 @@ 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", + "page": "api-ref/fab-api-ref", "spec": fab_auth_manager_fastapi_api_path.as_posix(), "opts": { "hide-hostname": True, 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/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/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..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 :doc:`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 2acfa4ed72648..d725da5696cf1 100644 --- a/providers/fab/docs/index.rst +++ b/providers/fab/docs/index.rst @@ -47,8 +47,7 @@ :hidden: :caption: References - Fab auth manager API - Fab auth manager token API + Fab auth manager API .. toctree:: :hidden: @@ -104,9 +103,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 +116,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 deleted file mode 100644 index 69e1a1965bf96..0000000000000 --- a/providers/fab/src/airflow/providers/fab/www/constants.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 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/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/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, 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) diff --git a/providers/google/tests/conftest.py b/providers/google/tests/conftest.py index f56ccce0a3f69..1cae3e67b641f 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") 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..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 @@ -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() @@ -149,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):