Coverage for /home/airflow/.local/lib/python3.12/site-packages/airflow/api_fastapi/execution_api/security.py: 68%
95 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.
18"""
19Execution API security: JWT validation, token scopes, and route-level access control.
21Token types (``TokenType``):
23``"execution"``
24 Default scope, accepted by all endpoints. Short-lived, automatically
25 refreshed by ``JWTReissueMiddleware``.
27``"workload"``
28 Restricted scope, only accepted on routes that opt in via
29 ``Security(require_auth, scopes=["token:workload"])``.
31Tokens without a ``scope`` claim default to ``"execution"`` for backwards
32compatibility (``claims.setdefault("scope", "execution")``).
34Enforcement flow:
35 1. ``JWTBearer.__call__`` validates the JWT once per request (crypto +
36 signature verification), caching the result on the ASGI request scope.
37 Subsequent FastAPI dependency resolutions and Cadwyn replays return
38 the cache.
39 2. ``require_auth`` is the Security dependency on routers. It receives
40 the token from ``JWTBearer`` and enforces:
41 - Token type against the route's ``allowed_token_types`` (precomputed
42 by ``ExecutionAPIRoute`` from ``token:*`` Security scopes).
43 - ``ti:self`` scope — checks that the JWT ``sub`` matches the
44 ``{task_instance_id}`` path parameter.
45 3. ``ExecutionAPIRoute`` precomputes ``allowed_token_types`` from
46 ``token:*`` Security scopes at route registration time. Routes
47 without explicit ``token:*`` scopes default to execution-only.
49Why ``ExecutionAPIRoute`` is needed:
50 FastAPI resolves router-level ``Security()`` dependencies from outermost
51 to innermost. A ``token:workload`` scope on an inner endpoint would need
52 to *relax* the outer router's default execution-only restriction, but
53 ``SecurityScopes`` only accumulate additively — an outer dependency
54 cannot see scopes declared by inner ones. ``ExecutionAPIRoute`` solves
55 this by inspecting the **merged** dependency list at route registration
56 time (after ``include_router`` has combined all parent and child
57 dependencies) and precomputing the full ``allowed_token_types`` set.
58 ``require_auth`` then reads this precomputed set from the matched route
59 at request time, avoiding the ordering problem entirely.
61 Any router whose routes need non-default token type policies must use
62 ``route_class=ExecutionAPIRoute``. Routers that only need the default
63 (execution-only) can use the standard route class — ``require_auth``
64 falls back to ``{"execution"}`` when the attribute is absent.
65"""
67# Disable future annotations in this file to work around https://github.com/fastapi/fastapi/issues/13056
68# ruff: noqa: I002
70from typing import Any, get_args
72import structlog
73from fastapi import Depends, HTTPException, Request, status
74from fastapi.params import Security as SecurityParam
75from fastapi.routing import APIRoute
76from fastapi.security import HTTPBearer, SecurityScopes
77from pydantic import ValidationError
78from sqlalchemy import select
80from airflow.api_fastapi.auth.tokens import JWTValidator
81from airflow.api_fastapi.execution_api.datamodels.token import TIClaims, TIToken, TokenScope
82from airflow.api_fastapi.execution_api.deps import DepContainer
84log = structlog.get_logger(logger_name=__name__)
86VALID_TOKEN_TYPES: frozenset[str] = frozenset(get_args(TokenScope))
88_REQUEST_SCOPE_TOKEN_KEY = "ti_token"
91class JWTBearer(HTTPBearer):
92 """
93 Validates JWT tokens for the Execution API.
95 Performs cryptographic validation once per request and caches the result
96 on the ASGI request scope. Subsequent resolutions (FastAPI dependency
97 dedup or Cadwyn replays) return the cached token.
99 This dependency handles ONLY crypto validation and token construction.
100 All route-specific authorization (token type, ti:self) is handled by
101 ``require_auth``.
102 """
104 def __init__(self, required_claims: dict[str, Any] | None = None):
105 super().__init__(auto_error=False)
106 self.required_claims = required_claims or {}
108 async def __call__( # type: ignore[override]
109 self,
110 request: Request,
111 services=DepContainer,
112 ) -> TIToken | None:
113 # Return cached token (handles both FastAPI dependency dedup and Cadwyn replays).
114 if cached := request.scope.get(_REQUEST_SCOPE_TOKEN_KEY):
115 return cached
117 # First resolution — full cryptographic validation.
118 creds = await super().__call__(request)
119 if not creds: 119 ↛ 120line 119 didn't jump to line 120 because the condition on line 119 was never true
120 raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Missing auth token")
122 validator: JWTValidator = await services.aget(JWTValidator)
124 try:
125 claims = await validator.avalidated_claims(creds.credentials, dict(self.required_claims))
126 except Exception:
127 log.warning("Failed to validate JWT", exc_info=True)
128 raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Invalid auth token")
130 claims.setdefault("scope", "execution")
132 try:
133 claim_model = TIClaims(**claims)
134 except ValidationError as err:
135 log.warning("JWT claims did not match task identity token schema", exc_info=True)
136 raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=f"Invalid auth token: {err}")
138 token = TIToken(id=claims["sub"], claims=claim_model)
139 request.scope[_REQUEST_SCOPE_TOKEN_KEY] = token
140 return token
143_jwt_bearer = JWTBearer()
146async def require_auth(
147 security_scopes: SecurityScopes,
148 request: Request,
149 token: TIToken = Depends(_jwt_bearer),
150) -> TIToken:
151 """
152 Security dependency that enforces token type and ``ti:self`` scope.
154 Used via ``Security(require_auth)`` on routers. ``SecurityScopes`` are
155 accumulated by FastAPI from all parent ``Security()`` declarations.
157 Token type enforcement reads ``route.allowed_token_types`` (precomputed
158 by ``ExecutionAPIRoute``) or defaults to ``{"execution"}``.
159 """
160 token_scope = token.claims.scope
162 if token_scope not in VALID_TOKEN_TYPES: 162 ↛ 163line 162 didn't jump to line 163 because the condition on line 162 was never true
163 log.warning("Invalid token scope in claims", token_scope=token_scope, path=request.url.path)
164 raise HTTPException(
165 status_code=status.HTTP_403_FORBIDDEN,
166 detail=f"Invalid token scope: {token_scope}",
167 )
169 route = request.scope.get("route")
170 allowed_token_types = getattr(route, "allowed_token_types", frozenset({"execution"}))
172 if token_scope not in allowed_token_types: 172 ↛ 173line 172 didn't jump to line 173 because the condition on line 172 was never true
173 log.warning(
174 "Token type not allowed for endpoint",
175 token_scope=token_scope,
176 allowed_types=sorted(allowed_token_types),
177 path=request.url.path,
178 )
179 raise HTTPException(
180 status_code=status.HTTP_403_FORBIDDEN,
181 detail=f"Token type '{token_scope}' not allowed for this endpoint. "
182 f"Allowed types: {', '.join(sorted(allowed_token_types))}",
183 )
185 if "ti:self" in security_scopes.scopes:
186 ti_self_id = str(request.path_params["task_instance_id"])
187 if str(token.id) != ti_self_id: 187 ↛ 188line 187 didn't jump to line 188 because the condition on line 187 was never true
188 raise HTTPException(
189 status_code=status.HTTP_403_FORBIDDEN,
190 detail="Token subject does not match task instance ID",
191 )
192 elif "ct:self" in security_scopes.scopes: 192 ↛ 193line 192 didn't jump to line 193 because the condition on line 192 was never true
193 ct_self_id = str(request.path_params["connection_test_id"])
194 if str(token.id) != ct_self_id:
195 raise HTTPException(
196 status_code=status.HTTP_403_FORBIDDEN,
197 detail="Token subject does not match connection test ID",
198 )
200 return token
203CurrentTIToken: TIToken = Depends(require_auth)
206class ExecutionAPIRoute(APIRoute):
207 """
208 Custom route class that precomputes allowed token types from Security scopes.
210 Scopes prefixed with ``token:`` (e.g., ``token:execution``, ``token:workload``)
211 are extracted at route registration time and stored as ``allowed_token_types``.
212 If no ``token:*`` scopes are declared, defaults to ``{"execution"}``.
214 ``require_auth`` reads ``route.allowed_token_types`` at request time.
215 """
217 allowed_token_types: frozenset[str]
219 def __init__(self, *args: Any, **kwargs: Any) -> None:
220 super().__init__(*args, **kwargs)
222 all_scopes: set[str] = set()
223 for dep in self.dependencies:
224 if isinstance(dep, SecurityParam):
225 all_scopes.update(dep.scopes or [])
227 token_scopes = {s.removeprefix("token:") for s in all_scopes if s.startswith("token:")}
229 if token_scopes and not token_scopes <= VALID_TOKEN_TYPES: 229 ↛ 230line 229 didn't jump to line 230 because the condition on line 229 was never true
230 invalid = token_scopes - VALID_TOKEN_TYPES
231 raise ValueError(f"Invalid token types in Security scopes: {invalid}")
233 self.allowed_token_types = frozenset(token_scopes) if token_scopes else frozenset({"execution"})
236async def get_team_name_dep(token=CurrentTIToken) -> str | None:
237 """Return the team name associated to the task (if any)."""
238 from airflow.configuration import conf
240 if not conf.getboolean("core", "multi_team"): 240 ↛ 243line 240 didn't jump to line 243 because the condition on line 240 was always true
241 return None
243 from airflow.utils.session import create_session_async
245 async with create_session_async() as session:
246 return await session.scalar(_team_name_for_ti_stmt(token.id))
249def get_team_name_for_ti(ti_id, session) -> str | None:
250 """
251 Return the team name associated to the task (if any), using a sync session.
253 Sync counterpart to :func:`get_team_name_dep` for callers that already hold a
254 SQLAlchemy session (e.g., the ``ti_run`` endpoint). No-op when multi-team is disabled.
255 """
256 from airflow.configuration import conf
258 if not conf.getboolean("core", "multi_team"): 258 ↛ 260line 258 didn't jump to line 260 because the condition on line 258 was always true
259 return None
260 return session.scalar(_team_name_for_ti_stmt(ti_id))
263def _team_name_for_ti_stmt(ti_id):
264 """Build the select statement resolving ``TaskInstance.id -> Team.name``."""
265 from airflow.models import DagModel, TaskInstance
266 from airflow.models.dagbundle import DagBundleModel
267 from airflow.models.team import Team
269 return (
270 select(Team.name)
271 .select_from(TaskInstance)
272 .join(DagModel, DagModel.dag_id == TaskInstance.dag_id)
273 .join(DagBundleModel, DagBundleModel.name == DagModel.bundle_name)
274 .join(DagBundleModel.teams)
275 .where(TaskInstance.id == ti_id)
276 )
279def _team_name_for_dag_stmt(dag_id):
280 """Build the select statement resolving ``DagModel.dag_id -> Team.name``."""
281 from airflow.models import DagModel
282 from airflow.models.dagbundle import DagBundleModel
283 from airflow.models.team import Team
285 return (
286 select(Team.name)
287 .select_from(DagModel)
288 .join(DagBundleModel, DagBundleModel.name == DagModel.bundle_name)
289 .join(DagBundleModel.teams)
290 .where(DagModel.dag_id == dag_id)
291 )