Skip to content

Commit 82215cf

Browse files
seanghaeliashb
andauthored
Add single-use callback token for deadline callback context fetch (#71192)
Deadline callbacks run in a subprocess that needs to read the DagRun context (and connections/variables/xcoms) from the Execution API. PR also accept the long-lived ``workload`` token via ``token:workload`` opt-ins. That over-broadened the workload token's reach (scope creep): a long-lived token could read arbitrary DagRun/connection/variable/xcom data for the whole queue-wait lifetime, and Ash asked for a single-use credential instead. This re-lands the security core of #66608 with a tighter design: --------- Co-authored-by: Ash Berlin-Taylor <ash_github@firemirror.com>
1 parent e00e532 commit 82215cf

22 files changed

Lines changed: 463 additions & 25 deletions

File tree

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
Callbacks now redeem a single-use token before they run
2+
3+
A worker now redeems a callback's single-use token with the API server
4+
(``PATCH /execution/callbacks/{callback_id}/run``) before importing and running the callback.
5+
Redeeming the token moves the callback from queued to running and swaps it for a short-lived
6+
execution-scoped token. Before this change a callback ran without the worker making any
7+
authenticated call, so the token minted for it was never checked.
8+
9+
Because the token is single-use, a redelivered or replayed message that reaches a callback that is
10+
already running or finished is refused rather than run again. A worker on 3.4 needs an API server
11+
that serves this endpoint; a worker talking to an older API server negotiates the API version and
12+
behaves as though the endpoint is not there.

airflow-core/src/airflow/api_fastapi/execution_api/app.py

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -148,10 +148,10 @@ async def dispatch(self, request: Request, call_next):
148148
validator: JWTValidator = await services.aget(JWTValidator)
149149
claims = await validator.avalidated_claims(token, {})
150150

151-
# Workload tokens are long-lived and meant to survive queue
152-
# wait times so avoid refreshing them. If avalidated_claims
153-
# raises for a workload token, the outer except handles it.
154-
if claims.get("scope") == "workload":
151+
# Workload and callback tokens are long-lived and meant to survive
152+
# queue wait times so avoid refreshing them. If avalidated_claims
153+
# raises for such a token, the outer except handles it.
154+
if claims.get("scope") in ("workload", "callback"):
155155
return response
156156

157157
now = int(time.time())

airflow-core/src/airflow/api_fastapi/execution_api/datamodels/token.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@
2424

2525
from airflow.api_fastapi.core_api.base import BaseModel
2626

27-
TokenScope = Literal["execution", "workload"]
27+
TokenScope = Literal["execution", "workload", "callback"]
2828

2929

3030
class TIClaims(BaseModel):

airflow-core/src/airflow/api_fastapi/execution_api/routes/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@
2323
asset_events,
2424
asset_state_store,
2525
assets,
26+
callbacks,
2627
connection_tests,
2728
connections,
2829
dag_runs,
@@ -53,6 +54,7 @@
5354
connection_tests.router, prefix="/connection-tests", tags=["Connection Tests"]
5455
)
5556
authenticated_router.include_router(connections.router, prefix="/connections", tags=["Connections"])
57+
authenticated_router.include_router(callbacks.router, prefix="/callbacks", tags=["Callbacks"])
5658
authenticated_router.include_router(dag_runs.router, prefix="/dag-runs", tags=["Dag Runs"])
5759
authenticated_router.include_router(dags.router, prefix="/dags", tags=["Dags"])
5860
authenticated_router.include_router(task_instances.router, prefix="/task-instances", tags=["Task Instances"])
Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
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.
17+
from __future__ import annotations
18+
19+
from uuid import UUID
20+
21+
from cadwyn import VersionedAPIRouter
22+
from fastapi import HTTPException, Response, Security, status
23+
24+
from airflow.api_fastapi.common.db.common import SessionDep
25+
from airflow.api_fastapi.core_api.openapi.exceptions import create_openapi_http_exception_doc
26+
from airflow.api_fastapi.execution_api.deps import DepContainer
27+
from airflow.api_fastapi.execution_api.security import (
28+
ExecutionAPIRoute,
29+
issue_execution_token,
30+
require_auth,
31+
)
32+
from airflow.models.callback import Callback
33+
from airflow.utils.state import CallbackState
34+
35+
router = VersionedAPIRouter(
36+
route_class=ExecutionAPIRoute,
37+
dependencies=[
38+
Security(require_auth, scopes=["cb:self", "token:callback"]),
39+
],
40+
)
41+
42+
43+
@router.patch(
44+
"/{callback_id}/run",
45+
status_code=status.HTTP_204_NO_CONTENT,
46+
responses=create_openapi_http_exception_doc(
47+
[
48+
(status.HTTP_404_NOT_FOUND, "Callback not found"),
49+
(status.HTTP_409_CONFLICT, "The callback token was already exchanged"),
50+
]
51+
),
52+
)
53+
def run_callback(
54+
callback_id: UUID,
55+
response: Response,
56+
session: SessionDep,
57+
services=DepContainer,
58+
) -> None:
59+
"""Exchange a single-use callback token for a short-lived execution token."""
60+
callback = session.get(Callback, callback_id, with_for_update=True)
61+
if callback is None:
62+
raise HTTPException(
63+
status_code=status.HTTP_404_NOT_FOUND,
64+
detail={
65+
"reason": "not_found",
66+
"message": f"Callback {callback_id} not found",
67+
},
68+
)
69+
70+
if callback.state != CallbackState.QUEUED:
71+
raise HTTPException(
72+
status_code=status.HTTP_409_CONFLICT,
73+
detail={
74+
"reason": "invalid_state",
75+
"message": (
76+
f"Callback {callback_id} is in state {callback.state}; its token can only be "
77+
"exchanged once while QUEUED."
78+
),
79+
"previous_state": callback.state,
80+
},
81+
)
82+
83+
callback.state = CallbackState.RUNNING
84+
85+
issue_execution_token(services, response, sub=str(callback_id))

airflow-core/src/airflow/api_fastapi/execution_api/routes/task_instances.py

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,6 @@
4343
from airflow._shared.observability.traces import override_ids
4444
from airflow._shared.state import TaskScope
4545
from airflow._shared.timezones import timezone
46-
from airflow.api_fastapi.auth.tokens import JWTGenerator
4746
from airflow.api_fastapi.common.dagbag import DagBagDep, get_latest_version_of_dag
4847
from airflow.api_fastapi.common.db.common import SessionDep
4948
from airflow.api_fastapi.common.db.dags import eager_load_teams
@@ -75,6 +74,7 @@
7574
CurrentTIToken,
7675
ExecutionAPIRoute,
7776
get_team_name_for_ti,
77+
issue_execution_token,
7878
require_auth,
7979
)
8080
from airflow.api_fastapi.execution_api.services.task_instances import (
@@ -346,9 +346,7 @@ def ti_run(
346346

347347
# JWTReissueMiddleware also writes Refreshed-API-Token but skips workload tokens, so we set it here for the workload→execution swap.
348348
if token.claims.scope == "workload":
349-
generator: JWTGenerator = services.get(JWTGenerator)
350-
execution_token = generator.generate(extras={"sub": str(task_instance_id), "scope": "execution"})
351-
response.headers["Refreshed-API-Token"] = execution_token
349+
issue_execution_token(services, response, sub=str(task_instance_id))
352350

353351
return context
354352

airflow-core/src/airflow/api_fastapi/execution_api/security.py

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -70,14 +70,15 @@
7070
from typing import Any, get_args
7171

7272
import structlog
73-
from fastapi import Depends, HTTPException, Request, status
73+
import svcs
74+
from fastapi import Depends, HTTPException, Request, Response, status
7475
from fastapi.params import Security as SecurityParam
7576
from fastapi.routing import APIRoute
7677
from fastapi.security import HTTPBearer, SecurityScopes
7778
from pydantic import ValidationError
7879
from sqlalchemy import select
7980

80-
from airflow.api_fastapi.auth.tokens import JWTValidator
81+
from airflow.api_fastapi.auth.tokens import JWTGenerator, JWTValidator
8182
from airflow.api_fastapi.execution_api.datamodels.token import TIClaims, TIToken, TokenScope
8283
from airflow.api_fastapi.execution_api.deps import DepContainer
8384

@@ -196,13 +197,26 @@ async def require_auth(
196197
status_code=status.HTTP_403_FORBIDDEN,
197198
detail="Token subject does not match connection test ID",
198199
)
200+
elif "cb:self" in security_scopes.scopes:
201+
cb_self_id = str(request.path_params["callback_id"])
202+
if str(token.id) != cb_self_id:
203+
raise HTTPException(
204+
status_code=status.HTTP_403_FORBIDDEN,
205+
detail="Token subject does not match callback ID",
206+
)
199207

200208
return token
201209

202210

203211
CurrentTIToken: TIToken = Depends(require_auth)
204212

205213

214+
def issue_execution_token(services: svcs.Container, response: Response, sub: str) -> None:
215+
"""Mint an ``execution``-scoped token and set it on the ``Refreshed-API-Token`` header."""
216+
generator: JWTGenerator = services.get(JWTGenerator)
217+
response.headers["Refreshed-API-Token"] = generator.generate(extras={"sub": sub, "scope": "execution"})
218+
219+
206220
class ExecutionAPIRoute(APIRoute):
207221
"""
208222
Custom route class that precomputes allowed token types from Security scopes.

airflow-core/src/airflow/api_fastapi/execution_api/versions/__init__.py

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -51,11 +51,14 @@
5151
AddTeamNameField,
5252
AddVariableKeysEndpoint,
5353
)
54-
from airflow.api_fastapi.execution_api.versions.v2026_10_30 import AddArgBindingsToTIRunContext
54+
from airflow.api_fastapi.execution_api.versions.v2026_10_30 import (
55+
AddArgBindingsToTIRunContext,
56+
AddCallbackRunEndpoint,
57+
)
5558

5659
bundle = VersionBundle(
5760
HeadVersion(),
58-
Version("2026-10-30", AddArgBindingsToTIRunContext),
61+
Version("2026-10-30", AddArgBindingsToTIRunContext, AddCallbackRunEndpoint),
5962
Version(
6063
"2026-06-30",
6164
AddVariableKeysEndpoint,

airflow-core/src/airflow/api_fastapi/execution_api/versions/v2026_10_30.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,8 +19,10 @@
1919

2020
from cadwyn import (
2121
ResponseInfo,
22+
VersionChange,
2223
VersionChangeWithSideEffects,
2324
convert_response_to_previous_version_for,
25+
endpoint,
2426
schema,
2527
)
2628

@@ -40,3 +42,13 @@ class AddArgBindingsToTIRunContext(VersionChangeWithSideEffects):
4042
def remove_arg_bindings_field(response: ResponseInfo) -> None: # type: ignore[misc]
4143
"""Strip ``arg_bindings`` from the run context for older clients."""
4244
response.body.pop("arg_bindings", None)
45+
46+
47+
class AddCallbackRunEndpoint(VersionChange):
48+
"""Add the callbacks/{callback_id}/run endpoint a worker uses to exchange its single-use callback token."""
49+
50+
description = __doc__
51+
52+
instructions_to_migrate_to_previous_version = (
53+
endpoint("/callbacks/{callback_id}/run", ["PATCH"]).didnt_exist,
54+
)

airflow-core/src/airflow/executors/workloads/base.py

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@
2121
import os
2222
from abc import ABC, abstractmethod
2323
from collections.abc import Hashable
24-
from typing import TYPE_CHECKING, Any
24+
from typing import TYPE_CHECKING, Any, ClassVar
2525

2626
from pydantic import BaseModel, ConfigDict, Field
2727

@@ -83,13 +83,16 @@ class BaseWorkloadSchema(BaseModel):
8383
token: str = Field(repr=False)
8484
"""The identity token for this workload"""
8585

86-
@staticmethod
87-
def generate_token(sub_id: str, generator: JWTGenerator | None = None) -> str:
86+
token_scope: ClassVar[str] = "workload"
87+
"""Scope claim stamped into tokens minted for this workload type."""
88+
89+
@classmethod
90+
def generate_token(cls, sub_id: str, generator: JWTGenerator | None = None) -> str:
8891
if not generator:
8992
return ""
9093
valid_for = conf.getfloat("scheduler", "task_queued_timeout")
9194
return generator.generate(
92-
extras={"sub": sub_id, "scope": "workload"},
95+
extras={"sub": sub_id, "scope": cls.token_scope},
9396
valid_for=valid_for,
9497
)
9598

0 commit comments

Comments
 (0)