Coverage for /home/airflow/.local/lib/python3.12/site-packages/airflow/api_fastapi/core_api/routes/public/connections.py: 52%
155 statements
« prev ^ index » next coverage.py v7.15.2, created at 2026-10-07 14:22 +0000
« prev ^ index » next coverage.py v7.15.2, created at 2026-10-07 14:22 +0000
1# Licensed to the Apache Software Foundation (ASF) under one
2# or more contributor license agreements. See the NOTICE file
3# distributed with this work for additional information
4# regarding copyright ownership. The ASF licenses this file
5# to you under the Apache License, Version 2.0 (the
6# "License"); you may not use this file except in compliance
7# with the License. You may obtain a copy of the License at
8#
9# http://www.apache.org/licenses/LICENSE-2.0
10#
11# Unless required by applicable law or agreed to in writing,
12# software distributed under the License is distributed on an
13# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
14# KIND, either express or implied. See the License for the
15# specific language governing permissions and limitations
16# under the License.
17from __future__ import annotations
19import os
20from typing import TYPE_CHECKING, Annotated
22from fastapi import Depends, Header, HTTPException, Query, status
23from fastapi.exceptions import RequestValidationError
24from pydantic import ValidationError
25from sqlalchemy import select
26from sqlalchemy.exc import IntegrityError
28from airflow.api_fastapi.app import get_auth_manager
29from airflow.api_fastapi.auth.managers.models.resource_details import ConnectionDetails
30from airflow.api_fastapi.common.db.common import SessionDep, paginated_select
31from airflow.api_fastapi.common.parameters import (
32 QueryConnectionIdPatternSearch,
33 QueryConnectionIdPrefixPatternSearch,
34 QueryLimit,
35 QueryOffset,
36 SortParam,
37)
38from airflow.api_fastapi.common.router import AirflowRouter
39from airflow.api_fastapi.compat import HTTP_422_UNPROCESSABLE_CONTENT
40from airflow.api_fastapi.core_api.datamodels.common import (
41 BulkBody,
42 BulkResponse,
43)
44from airflow.api_fastapi.core_api.datamodels.connections import (
45 AsyncConnectionTestResponse,
46 ConnectionBody,
47 ConnectionBodyPartial,
48 ConnectionCollectionResponse,
49 ConnectionResponse,
50 ConnectionTestQueuedResponse,
51 ConnectionTestRequestBody,
52 ConnectionTestResponse,
53)
54from airflow.api_fastapi.core_api.openapi.exceptions import create_openapi_http_exception_doc
55from airflow.api_fastapi.core_api.security import (
56 AuthManagerDep,
57 GetUserDep,
58 ReadableConnectionsFilterDep,
59 requires_access_connection,
60 requires_access_connection_bulk,
61)
62from airflow.api_fastapi.core_api.services.public.connections import (
63 BulkConnectionService,
64 update_orm_from_pydantic,
65)
66from airflow.api_fastapi.logging.decorators import action_logging
67from airflow.configuration import conf
68from airflow.exceptions import AirflowNotFoundException
69from airflow.executors.executor_loader import ExecutorLoader
70from airflow.models import Connection
71from airflow.models.connection_test import ConnectionTestRequest
72from airflow.secrets.environment_variables import CONN_ENV_PREFIX
73from airflow.utils.db import create_default_connections as db_create_default_connections
74from airflow.utils.strings import get_random_string
76if TYPE_CHECKING: 76 ↛ 77line 76 didn't jump to line 77 because the condition on line 76 was never true
77 from airflow.api_fastapi.auth.managers.base_auth_manager import ResourceMethod
79connections_router = AirflowRouter(tags=["Connection"], prefix="/connections")
82def _ensure_test_connection_enabled() -> None:
83 """Raise 403 if connection testing is not enabled in the Airflow configuration."""
84 if conf.get("core", "test_connection", fallback="Disabled").lower().strip() != "enabled": 84 ↛ exitline 84 didn't return from function '_ensure_test_connection_enabled' because the condition on line 84 was always true
85 raise HTTPException(
86 status.HTTP_403_FORBIDDEN,
87 "Testing connections is disabled in Airflow configuration. "
88 "Contact your deployment admin to enable it.",
89 )
92def _ensure_executor_is_configured(executor: str | None) -> None:
93 """Raise 422 if the requested executor is not in the configured executors list."""
94 if executor is None:
95 return
96 configured = ExecutorLoader.get_executor_names(validate_teams=False)
97 if not any(
98 executor in (name.alias, name.module_path, name.module_path.split(".")[-1]) for name in configured
99 ):
100 raise HTTPException(
101 HTTP_422_UNPROCESSABLE_CONTENT,
102 f"Executor '{executor}' is not configured. "
103 f"Configured executors: {[name.alias or name.module_path for name in configured]}",
104 )
107_MASKED_CREDENTIAL_SENTINEL = "***"
110def _same_endpoint(requested: str | int | None, stored: str | int | None) -> bool:
111 """
112 Return True when request and stored host/port refer to the same destination.
114 The UI sends empty string for hidden unused host/port fields; the ORM stores
115 those as NULL. Treat blank as unset so connection types that do not use
116 host/port still reuse stored credentials.
117 """
119 def _norm(value: str | int | None) -> str | int | None:
120 return None if value is None or value == "" else value
122 return _norm(requested) == _norm(stored)
125def _supplies_own_credentials(test_body: ConnectionBody) -> bool:
126 """
127 Return True when the request includes a real (non-masked) password.
129 The UI always posts the masked sentinel for unchanged secrets. That is not
130 a caller-supplied credential and must not skip restoring stored extras.
131 """
132 if "password" not in test_body.model_fields_set:
133 return False
134 password = test_body.password
135 return bool(password) and password != _MASKED_CREDENTIAL_SENTINEL
138@connections_router.delete(
139 "/{connection_id}",
140 status_code=status.HTTP_204_NO_CONTENT,
141 responses=create_openapi_http_exception_doc([status.HTTP_404_NOT_FOUND]),
142 dependencies=[Depends(requires_access_connection(method="DELETE")), Depends(action_logging())],
143)
144def delete_connection(
145 connection_id: str,
146 session: SessionDep,
147):
148 """Delete a connection entry."""
149 connection = session.scalar(select(Connection).filter_by(conn_id=connection_id))
151 if connection is None:
152 raise HTTPException(
153 status.HTTP_404_NOT_FOUND, f"The Connection with connection_id: `{connection_id}` was not found"
154 )
156 session.delete(connection)
159@connections_router.get(
160 "/enqueue-test",
161 responses=create_openapi_http_exception_doc([status.HTTP_404_NOT_FOUND]),
162)
163def get_connection_test(
164 session: SessionDep,
165 user: GetUserDep,
166 connection_test_token: Annotated[str, Header(alias="Airflow-Connection-Test-Token")],
167) -> AsyncConnectionTestResponse:
168 """Poll for the status of an enqueued connection test by its token (passed as a header)."""
169 connection_test = session.scalar(select(ConnectionTestRequest).filter_by(token=connection_test_token))
171 if connection_test is None: 171 ↛ 177line 171 didn't jump to line 177 because the condition on line 171 was always true
172 raise HTTPException(
173 status.HTTP_404_NOT_FOUND,
174 f"No connection test found for token: `{connection_test_token}`",
175 )
177 if not get_auth_manager().is_authorized_connection(
178 method="GET",
179 details=ConnectionDetails(conn_id=connection_test.connection_id, team_name=connection_test.team_name),
180 user=user,
181 ):
182 raise HTTPException(
183 status.HTTP_404_NOT_FOUND,
184 f"No connection test found for token: `{connection_test_token}`",
185 )
187 return AsyncConnectionTestResponse(
188 token=connection_test.token,
189 connection_id=connection_test.connection_id,
190 state=connection_test.state,
191 result_message=connection_test.result_message,
192 created_at=connection_test.created_at,
193 )
196@connections_router.get(
197 "/{connection_id}",
198 responses=create_openapi_http_exception_doc([status.HTTP_404_NOT_FOUND]),
199 dependencies=[Depends(requires_access_connection(method="GET"))],
200)
201def get_connection(
202 connection_id: str,
203 session: SessionDep,
204) -> ConnectionResponse:
205 """Get a connection entry."""
206 connection = session.scalar(select(Connection).filter_by(conn_id=connection_id))
208 if connection is None:
209 raise HTTPException(
210 status.HTTP_404_NOT_FOUND, f"The Connection with connection_id: `{connection_id}` was not found"
211 )
213 return connection
216@connections_router.get(
217 "",
218 responses=create_openapi_http_exception_doc([status.HTTP_404_NOT_FOUND]),
219 dependencies=[Depends(requires_access_connection(method="GET"))],
220)
221def get_connections(
222 limit: QueryLimit,
223 offset: QueryOffset,
224 order_by: Annotated[
225 SortParam,
226 Depends(
227 SortParam(
228 ["conn_id", "conn_type", "description", "host", "port", "id", "team_name"],
229 Connection,
230 {"connection_id": "conn_id"},
231 ).dynamic_depends()
232 ),
233 ],
234 readable_connections_filter: ReadableConnectionsFilterDep,
235 session: SessionDep,
236 connection_id_pattern: QueryConnectionIdPatternSearch,
237 connection_id_prefix_pattern: QueryConnectionIdPrefixPatternSearch,
238) -> ConnectionCollectionResponse:
239 """Get all connection entries."""
240 connection_select, total_entries = paginated_select(
241 statement=select(Connection),
242 filters=[connection_id_pattern, connection_id_prefix_pattern, readable_connections_filter],
243 order_by=order_by,
244 offset=offset,
245 limit=limit,
246 session=session,
247 )
249 connections = session.scalars(connection_select)
251 return ConnectionCollectionResponse(
252 connections=connections,
253 total_entries=total_entries,
254 )
257@connections_router.post(
258 "",
259 status_code=status.HTTP_201_CREATED,
260 responses=create_openapi_http_exception_doc(
261 [status.HTTP_409_CONFLICT]
262 ), # handled by global exception handler
263 dependencies=[Depends(requires_access_connection(method="POST")), Depends(action_logging())],
264)
265def post_connection(
266 post_body: ConnectionBody,
267 session: SessionDep,
268) -> ConnectionResponse:
269 """Create connection entry."""
270 connection = Connection(**post_body.model_dump(by_alias=True))
271 session.add(connection)
272 return connection
275@connections_router.patch(
276 "", dependencies=[Depends(requires_access_connection_bulk()), Depends(action_logging())]
277)
278def bulk_connections(
279 request: BulkBody[ConnectionBody],
280 session: SessionDep,
281) -> BulkResponse:
282 """Bulk create, update, and delete connections."""
283 return BulkConnectionService(session=session, request=request).handle_request()
286@connections_router.patch(
287 "/{connection_id}",
288 responses=create_openapi_http_exception_doc(
289 [
290 status.HTTP_400_BAD_REQUEST,
291 status.HTTP_404_NOT_FOUND,
292 ]
293 ),
294 dependencies=[Depends(requires_access_connection(method="PUT")), Depends(action_logging())],
295)
296def patch_connection(
297 connection_id: str,
298 patch_body: ConnectionBody,
299 session: SessionDep,
300 update_mask: list[str] | None = Query(None),
301) -> ConnectionResponse:
302 """Update a connection entry."""
303 if patch_body.connection_id != connection_id:
304 raise HTTPException(
305 status.HTTP_400_BAD_REQUEST,
306 "The connection_id in the request body does not match the URL parameter",
307 )
309 connection = session.scalar(select(Connection).filter_by(conn_id=connection_id).limit(1))
311 if connection is None:
312 raise HTTPException(
313 status.HTTP_404_NOT_FOUND, f"The Connection with connection_id: `{connection_id}` was not found"
314 )
316 if update_mask:
317 fields_to_update = patch_body.model_fields_set & set(update_mask)
318 try:
319 ConnectionBodyPartial(**patch_body.model_dump(include=fields_to_update))
320 except ValidationError as e:
321 raise RequestValidationError(errors=e.errors())
323 update_orm_from_pydantic(connection, patch_body, update_mask)
324 return connection
327@connections_router.post("/test", dependencies=[Depends(requires_access_connection(method="POST"))])
328def test_connection(
329 test_body: ConnectionBody,
330 user: GetUserDep,
331 auth_manager: AuthManagerDep,
332) -> ConnectionTestResponse:
333 """
334 Test an API connection.
336 This method first creates an in-memory transient conn_id & exports that to an env var,
337 as some hook classes tries to find out the `conn` from their __init__ method & errors out if not found.
338 It also deletes the conn id env connection after the test.
339 """
340 _ensure_test_connection_enabled()
342 transient_conn_id = get_random_string()
343 conn_env_var = f"{CONN_ENV_PREFIX}{transient_conn_id.upper()}"
344 try:
345 # Authorize read access on the requested ``connection_id`` *before*
346 # touching the secrets backends. The route-level POST dependency only
347 # verifies the caller can create connections; merging the existing
348 # connection's hidden fields also requires read access to that
349 # specific connection. Gating the backend lookup itself (rather than
350 # the post-load merge) prevents an unauthorized caller from using
351 # this endpoint to enumerate protected connection ids, generate
352 # access-log entries in audited backends, or impose backend load for
353 # arbitrary ids. ``get_team_name`` is a metadata-only DB lookup and
354 # does not touch the configured secrets backends.
355 #
356 # When the connection has no metadata-DB row (e.g. it lives only in
357 # a team-aware secrets backend like Vault or Kubernetes), fall back
358 # to the request body's validated ``team_name`` so the GET
359 # authorization and the secrets lookup both run in the right team
360 # scope. ``ConnectionBody.validate_team_name`` already rejects
361 # ``team_name`` from clients when ``[core] multi_team`` is off, so
362 # a non-None body value here is always already gated by that
363 # validator.
364 team_name = Connection.get_team_name(test_body.connection_id)
365 if team_name is None:
366 team_name = test_body.team_name
367 existing_conn: Connection | None = None
368 if auth_manager.is_authorized_connection(
369 method="GET",
370 details=ConnectionDetails(
371 conn_id=test_body.connection_id,
372 team_name=team_name,
373 ),
374 user=user,
375 ):
376 try:
377 existing_conn = Connection.get_connection_from_secrets(
378 test_body.connection_id, team_name=team_name
379 )
380 except AirflowNotFoundException:
381 existing_conn = None
383 if existing_conn is not None:
384 # Stored credentials are only reused to test the connection's own
385 # host/port; testing a different destination must supply its own.
386 fields_set = test_body.model_fields_set
387 host_changed = "host" in fields_set and not _same_endpoint(test_body.host, existing_conn.host)
388 port_changed = "port" in fields_set and not _same_endpoint(test_body.port, existing_conn.port)
389 if host_changed or port_changed:
390 if not _supplies_own_credentials(test_body):
391 raise HTTPException(
392 status.HTTP_400_BAD_REQUEST,
393 "The host or port to test differs from the stored connection. "
394 "Include the credentials to test in the request body.",
395 )
396 existing_conn = None
398 if existing_conn is not None:
399 existing_conn.conn_id = transient_conn_id
400 update_orm_from_pydantic(existing_conn, test_body)
401 conn = existing_conn
402 else:
403 data = test_body.model_dump(by_alias=True)
404 data["conn_id"] = transient_conn_id
405 conn = Connection(**data)
407 os.environ[conn_env_var] = conn.get_uri()
408 test_status, test_message = conn.test_connection()
409 return ConnectionTestResponse.model_validate({"status": test_status, "message": test_message})
410 finally:
411 os.environ.pop(conn_env_var, None)
414@connections_router.post(
415 "/enqueue-test",
416 status_code=status.HTTP_202_ACCEPTED,
417 responses=create_openapi_http_exception_doc(
418 [
419 status.HTTP_403_FORBIDDEN,
420 status.HTTP_409_CONFLICT,
421 HTTP_422_UNPROCESSABLE_CONTENT,
422 ]
423 ),
424 dependencies=[Depends(action_logging())],
425)
426def enqueue_connection_test(
427 test_body: ConnectionTestRequestBody,
428 session: SessionDep,
429 user: GetUserDep,
430) -> ConnectionTestQueuedResponse:
431 """Enqueue a connection test for deferred execution on a worker; returns a polling token."""
432 _ensure_test_connection_enabled()
433 _ensure_executor_is_configured(test_body.executor)
435 existing = session.scalar(select(Connection).filter_by(conn_id=test_body.connection_id))
436 if existing is not None:
437 effective_team = existing.team_name
438 if test_body.team_name is not None and test_body.team_name != effective_team:
439 raise HTTPException(
440 status.HTTP_403_FORBIDDEN,
441 f"team_name `{test_body.team_name}` does not match the team of connection "
442 f"`{test_body.connection_id}`.",
443 )
444 else:
445 effective_team = test_body.team_name
447 auth_method: ResourceMethod = "PUT" if existing is not None and test_body.commit_on_success else "POST"
448 if not get_auth_manager().is_authorized_connection(
449 method=auth_method,
450 details=ConnectionDetails(conn_id=test_body.connection_id, team_name=effective_team),
451 user=user,
452 ):
453 raise HTTPException(
454 status.HTTP_403_FORBIDDEN,
455 f"You are not authorized to test connection `{test_body.connection_id}`.",
456 )
458 connection_test = ConnectionTestRequest(
459 connection_id=test_body.connection_id,
460 conn_type=test_body.conn_type,
461 host=test_body.host,
462 login=test_body.login,
463 password=test_body.password,
464 schema=test_body.schema_,
465 port=test_body.port,
466 extra=test_body.extra,
467 commit_on_success=test_body.commit_on_success,
468 executor=test_body.executor,
469 queue=test_body.queue,
470 team_name=effective_team,
471 )
472 session.add(connection_test)
473 try:
474 session.flush()
475 except IntegrityError:
476 raise HTTPException(
477 status.HTTP_409_CONFLICT,
478 f"An active connection test already exists for connection_id `{test_body.connection_id}`.",
479 )
481 return ConnectionTestQueuedResponse(
482 token=connection_test.token,
483 connection_id=connection_test.connection_id,
484 state=connection_test.state,
485 )
488@connections_router.post(
489 "/defaults",
490 status_code=status.HTTP_204_NO_CONTENT,
491 dependencies=[Depends(requires_access_connection(method="POST")), Depends(action_logging())],
492)
493def create_default_connections(
494 session: SessionDep,
495):
496 """Create default connections."""
497 db_create_default_connections(session=session)