-
Notifications
You must be signed in to change notification settings - Fork 14.5k
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Do not use core Airflow Flask related resources in FAB provider (tests of www
)
#45472
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,125 @@ | ||
# 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 functools import wraps | ||
from typing import TYPE_CHECKING, Callable, TypeVar, cast | ||
|
||
from flask import flash, redirect, render_template, request, url_for | ||
|
||
from airflow.api_fastapi.app import get_auth_manager | ||
from airflow.auth.managers.models.resource_details import ( | ||
AccessView, | ||
DagAccessEntity, | ||
DagDetails, | ||
) | ||
from airflow.configuration import conf | ||
from airflow.utils.net import get_hostname | ||
|
||
if TYPE_CHECKING: | ||
from airflow.auth.managers.base_auth_manager import ResourceMethod | ||
|
||
T = TypeVar("T", bound=Callable) | ||
|
||
log = logging.getLogger(__name__) | ||
|
||
|
||
def get_access_denied_message(): | ||
return conf.get("webserver", "access_denied_message") | ||
|
||
|
||
def _has_access(*, is_authorized: bool, func: Callable, args, kwargs): | ||
""" | ||
Define the behavior whether the user is authorized to access the resource. | ||
:param is_authorized: 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: | ||
""" | ||
if is_authorized: | ||
return func(*args, **kwargs) | ||
elif get_auth_manager().is_logged_in() and not get_auth_manager().is_authorized_view( | ||
access_view=AccessView.WEBSITE | ||
): | ||
return ( | ||
render_template( | ||
"airflow/no_roles_permissions.html", | ||
hostname=get_hostname() if conf.getboolean("webserver", "EXPOSE_HOSTNAME") else "", | ||
logout_url=get_auth_manager().get_url_logout(), | ||
), | ||
403, | ||
) | ||
elif not get_auth_manager().is_logged_in(): | ||
return redirect(get_auth_manager().get_url_login(next_url=request.url)) | ||
else: | ||
access_denied = get_access_denied_message() | ||
flash(access_denied, "danger") | ||
return redirect(url_for("Airflow.index")) | ||
|
||
|
||
def has_access_dag(method: ResourceMethod, access_entity: DagAccessEntity | None = None) -> Callable[[T], T]: | ||
def has_access_decorator(func: T): | ||
@wraps(func) | ||
def decorated(*args, **kwargs): | ||
dag_id_kwargs = kwargs.get("dag_id") | ||
dag_id_args = request.args.get("dag_id") | ||
dag_id_form = request.form.get("dag_id") | ||
dag_id_json = request.json.get("dag_id") if request.is_json else None | ||
all_dag_ids = [dag_id_kwargs, dag_id_args, dag_id_form, dag_id_json] | ||
unique_dag_ids = set(dag_id for dag_id in all_dag_ids if dag_id is not None) | ||
|
||
if len(unique_dag_ids) > 1: | ||
log.warning( | ||
"There are different dag_ids passed in the request: %s. Returning 403.", unique_dag_ids | ||
) | ||
log.warning( | ||
"kwargs: %s, args: %s, form: %s, json: %s", | ||
dag_id_kwargs, | ||
dag_id_args, | ||
dag_id_form, | ||
dag_id_json, | ||
) | ||
return ( | ||
render_template( | ||
"airflow/no_roles_permissions.html", | ||
hostname=get_hostname() if conf.getboolean("webserver", "EXPOSE_HOSTNAME") else "", | ||
logout_url=get_auth_manager().get_url_logout(), | ||
), | ||
403, | ||
) | ||
dag_id = unique_dag_ids.pop() if unique_dag_ids else None | ||
|
||
is_authorized = get_auth_manager().is_authorized_dag( | ||
method=method, | ||
access_entity=access_entity, | ||
details=None if not dag_id else DagDetails(id=dag_id), | ||
) | ||
|
||
return _has_access( | ||
is_authorized=is_authorized, | ||
func=func, | ||
args=args, | ||
kwargs=kwargs, | ||
) | ||
|
||
return cast(T, decorated) | ||
|
||
return has_access_decorator |
Original file line number | Diff line number | Diff line change | ||||
---|---|---|---|---|---|---|
|
@@ -18,17 +18,47 @@ | |||||
|
||||||
import logging | ||||||
from functools import cached_property | ||||||
from pathlib import Path | ||||||
from typing import TYPE_CHECKING | ||||||
|
||||||
from connexion import Resolver | ||||||
from connexion import FlaskApi, Resolver | ||||||
from connexion.decorators.validation import RequestBodyValidator | ||||||
from connexion.exceptions import BadRequestProblem | ||||||
from connexion.exceptions import BadRequestProblem, ProblemException | ||||||
from flask import request | ||||||
|
||||||
from airflow.api_connexion.exceptions import common_error_handler | ||||||
from airflow.api_fastapi.app import get_auth_manager | ||||||
from airflow.configuration import conf | ||||||
from airflow.providers.fab.www.constants import SWAGGER_BUNDLE, SWAGGER_ENABLED | ||||||
from airflow.utils.yaml import safe_load | ||||||
|
||||||
if TYPE_CHECKING: | ||||||
from flask import Flask | ||||||
|
||||||
log = logging.getLogger(__name__) | ||||||
|
||||||
# providers/src/airflow/providers/fab/www/extensions/init_views.py => airflow/ | ||||||
ROOT_APP_DIR = Path(__file__).parents[7].joinpath("airflow").resolve() | ||||||
|
||||||
|
||||||
def set_cors_headers_on_response(response): | ||||||
"""Add response headers.""" | ||||||
allow_headers = conf.get("api", "access_control_allow_headers") | ||||||
allow_methods = conf.get("api", "access_control_allow_methods") | ||||||
allow_origins = conf.get("api", "access_control_allow_origins") | ||||||
if allow_headers: | ||||||
response.headers["Access-Control-Allow-Headers"] = allow_headers | ||||||
if allow_methods: | ||||||
response.headers["Access-Control-Allow-Methods"] = allow_methods | ||||||
if allow_origins == "*": | ||||||
response.headers["Access-Control-Allow-Origin"] = "*" | ||||||
elif allow_origins: | ||||||
allowed_origins = allow_origins.split(" ") | ||||||
origin = request.environ.get("HTTP_ORIGIN", allowed_origins[0]) | ||||||
if origin in allowed_origins: | ||||||
response.headers["Access-Control-Allow-Origin"] = origin | ||||||
return response | ||||||
|
||||||
|
||||||
class _LazyResolution: | ||||||
""" | ||||||
|
@@ -78,6 +108,59 @@ def validate_schema(self, data, url): | |||||
return super().validate_schema(data, url) | ||||||
|
||||||
|
||||||
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.""" | ||||||
|
||||||
@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) | ||||||
else: | ||||||
from airflow.providers.fab.www.views import not_found | ||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The not_found function should be imported at the top of the file for clarity and to avoid potential issues with circular imports.
Suggested change
Copilot is powered by AI, so mistakes are possible. Review output carefully before use. |
||||||
|
||||||
return 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) | ||||||
else: | ||||||
from airflow.providers.fab.www.views import method_not_allowed | ||||||
|
||||||
return method_not_allowed(ex) | ||||||
|
||||||
app.register_error_handler(ProblemException, common_error_handler) | ||||||
|
||||||
|
||||||
def init_api_connexion(app: Flask) -> None: | ||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. We are probably at a point we can rip out the old api, to be honest - I think aip-84 is far enough along. If we want to keep it for now, that's fine, however we might want to leave a breadcrumb that this needs to go before AF3. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I agree. Though, removing it make some tests fail. Some tests in FAB provider are using Rest API endpoints (e.g. |
||||||
"""Initialize Stable API.""" | ||||||
base_path = "/api/v1" | ||||||
base_paths.append(base_path) | ||||||
|
||||||
with ROOT_APP_DIR.joinpath("api_connexion", "openapi", "v1.yaml").open() as f: | ||||||
specification = safe_load(f) | ||||||
api_bp = FlaskApi( | ||||||
specification=specification, | ||||||
resolver=_LazyResolver(), | ||||||
base_path=base_path, | ||||||
options={"swagger_ui": SWAGGER_ENABLED, "swagger_path": SWAGGER_BUNDLE.__fspath__()}, | ||||||
strict_validation=True, | ||||||
validate_responses=True, | ||||||
validator_map={"body": _CustomErrorRequestBodyValidator}, | ||||||
).blueprint | ||||||
api_bp.after_request(set_cors_headers_on_response) | ||||||
|
||||||
app.register_blueprint(api_bp) | ||||||
app.extensions["csrf"].exempt(api_bp) | ||||||
|
||||||
|
||||||
def init_plugins(app): | ||||||
"""Integrate Flask and FAB with plugins.""" | ||||||
from airflow import plugins_manager | ||||||
|
@@ -118,3 +201,13 @@ def init_error_handlers(app: Flask): | |||||
|
||||||
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) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The allow_origins variable should be split by commas instead of spaces to handle multiple origins more accurately.
Copilot is powered by AI, so mistakes are possible. Review output carefully before use.