Skip to content

Commit eb40af7

Browse files
committed
Fix Dag processor token rotation and registration failures
Provisioner restarts and transient write failures must not interrupt running processors. Failed registration must not leave an incomplete Job that a retry can adopt.
1 parent 55f3d19 commit eb40af7

28 files changed

Lines changed: 484 additions & 197 deletions

File tree

‎airflow-core/docs/security/jwt_token_authentication.rst‎

Lines changed: 14 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -337,7 +337,7 @@ The token flows through the execution stack as follows:
337337
Even if a workload token is intercepted in transit, it can only call ``/run``. That endpoint
338338
rejects re-runs (``409 Conflict`` unless the task instance is in ``QUEUED`` or ``RESTARTING``),
339339
so the attack surface for the longer-lived token is bounded to "start a task that is already
340-
queued". All other endpoints require ``scope=execution`` and reject workload tokens.
340+
queued". Every other endpoint rejects workload tokens.
341341

342342
Token validation (Execution API)
343343
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
@@ -358,7 +358,7 @@ The ``JWTBearer`` security dependency validates the token once per request:
358358
``403 Forbidden`` even when cryptographic validation succeeds. All claim types allow extra
359359
fields so auth managers can attach deployment-specific claims.
360360
6. Constructs the corresponding token object: ``TIToken``, ``DagProcessorSessionToken``,
361-
``DagProcessorToken``, or an ``ExecutionToken`` carrying ``DagParseClaims``.
361+
``DagProcessorToken``, or ``DagParseToken``.
362362
7. Caches the validated token on the ASGI request scope for the duration of the request.
363363

364364
Route-level enforcement is handled by ``require_auth``:
@@ -508,9 +508,12 @@ the client may access::
508508
airflow dag-processor-token --token-file /run/airflow/processor.jwt \
509509
--bundle-name dags-folder --rotate
510510

511-
The command writes the token atomically with owner-only permissions. Keep the provisioner
512-
running and mount its directory rather than a single file so token replacement stays
513-
visible. Each processor process needs its own session.
511+
The command writes the token atomically. A new file is readable only by its owner, and replacing
512+
an existing file keeps its mode and group, so grant the processor read access once. ACLs are not
513+
carried over. Keep the provisioner running and mount its directory rather than a single file so
514+
token replacement stays visible. Each processor process needs its own session. Restarting the
515+
provisioner with the same token file and bundles keeps that session, so running processors keep
516+
their Jobs.
514517

515518
The three credentials have different issuers and permissions:
516519

@@ -530,7 +533,12 @@ invalidates its runtime credentials.
530533

531534
The client selects the bundle with ``use_bundle`` and a parsing attempt with ``use_parse``.
532535
A request header cannot override the parsing token's signed bundle.
533-
Connection and Variable access uses the bundle's current team.
536+
Connection and Variable access uses the bundle's current team. A registered Job's teams come from
537+
``[dag_processor] dag_bundle_config_list`` on the API server, so it needs the same bundle-to-team
538+
configuration as the processor. A ``dag_parse`` token can also write and delete Variables, as a
539+
parsing subprocess can without this client. ``Variable.set`` upserts on the key alone, so in
540+
multi-team mode a write can overwrite another team's Variable with the same key, as it can with task
541+
tokens.
534542

535543
The client attempts renewal at 80% of token lifetime. Transient early-renewal failures leave
536544
the existing token usable until expiry. A retired registration signals that its owner must

‎airflow-core/src/airflow/api/common/airflow_health.py‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@
2323
from sqlalchemy import select
2424

2525
from airflow.configuration import conf
26-
from airflow.dag_processing.bundles.manager import _get_configured_bundle_team_names
26+
from airflow.dag_processing.bundles.manager import get_configured_bundle_team_names
2727
from airflow.jobs.dag_processor_job_runner import DagProcessorJobRunner
2828
from airflow.jobs.job import Job
2929
from airflow.jobs.scheduler_job_runner import SchedulerJobRunner
@@ -121,7 +121,7 @@ def _dag_processor_instance_health(job: Job) -> dict[str, Any]:
121121
def _configured_bundle_teams() -> dict[str, str | None]:
122122
"""Map every configured Dag bundle to the team owning it, empty when the config is unreadable."""
123123
try:
124-
return _get_configured_bundle_team_names()
124+
return get_configured_bundle_team_names()
125125
except Exception:
126126
# A health probe must not fail on malformed bundle config; callers fall back to liveness only.
127127
log.warning("Could not read the Dag bundle configuration", exc_info=True)

‎airflow-core/src/airflow/api_fastapi/execution_api/AGENTS.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -77,4 +77,4 @@ Adding a new Execution API feature touches multiple packages. All of these must
7777

7878
## Token Scope Infrastructure
7979

80-
Token types (`"execution"`, `"workload"`, `"callback"`, `"dag_processor_session"`, `"dag_processor"`), route-level enforcement via `ExecutionAPIRoute` + `require_auth`, and the `ti:self` path-parameter validation are documented in the module docstring of `security.py`. A route that admits `dag_processor` tokens must also bind the request to a granted Dag bundle; `test_token_scope_boundaries.py` lists those routes and the parse-time messages that use them.
80+
Token types (`"execution"`, `"workload"`, `"callback"`, `"dag_processor_session"`, `"dag_processor"`, `"dag_parse"`), route-level enforcement via `ExecutionAPIRoute` + `require_auth`, and the `ti:self` path-parameter validation are documented in the module docstring of `security.py`. A route that admits `dag_processor` or `dag_parse` tokens for Connection, Variable, XCom, Dag run or task-instance reads must also bind the request to a granted Dag bundle (`SelectedDagBundle` or `DagInGrantedBundle`); the `/jobs` routes bind to the `job_id` claim instead. `test_token_scope_boundaries.py` lists the bundle-bound routes and the parse-time messages that use them.

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

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -153,8 +153,9 @@ async def dispatch(self, request: Request, call_next):
153153

154154
# Only short-lived execution tokens are renewed here. Any other type has a
155155
# lifetime set by its issuer (workload and callback tokens outlive queue waits,
156-
# dag_processor tokens are rotated by provisioning), so a new type must not
157-
# become renewable by default. Tokens without a scope are legacy execution tokens.
156+
# the dag_processor_session token is rotated by provisioning, and Job and parsing
157+
# tokens are renewed by re-registering and re-exchanging), so a new type must
158+
# not become renewable by default. Tokens without a scope are legacy execution tokens.
158159
if claims.get("scope", "execution") != "execution":
159160
return response
160161

‎airflow-core/src/airflow/api_fastapi/execution_api/dag_processor_tokens.py‎

Lines changed: 27 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,8 @@
2323

2424
from __future__ import annotations
2525

26-
from typing import TYPE_CHECKING
26+
import math
27+
from typing import TYPE_CHECKING, NamedTuple
2728

2829
from airflow._shared.timezones import timezone
2930

@@ -34,10 +35,25 @@
3435
from airflow.api_fastapi.auth.tokens import JWTGenerator
3536

3637

38+
class IssuedToken(NamedTuple):
39+
"""A token with the seconds left until it expires."""
40+
41+
token: str
42+
expires_in: int
43+
44+
3745
class ExpiredDagProcessorToken(ValueError):
3846
"""The parent credential expired before a Job or parsing credential could be issued."""
3947

4048

49+
def _get_lifetime(generator: JWTGenerator, parent_expiry: float, message: str) -> int:
50+
"""Return whole seconds a child token may live: the signer's lifetime, capped by its parent's expiry."""
51+
lifetime = math.floor(min(generator.valid_for, parent_expiry - timezone.utcnow().timestamp()))
52+
if lifetime < 1:
53+
raise ExpiredDagProcessorToken(message)
54+
return lifetime
55+
56+
4157
def generate_dag_processor_session_token(
4258
generator: JWTGenerator, *, session_id: UUID, bundle_names: Collection[str], valid_for: float
4359
) -> str:
@@ -59,20 +75,19 @@ def generate_dag_processor_token(
5975
job_id: int,
6076
bundle_names: Collection[str],
6177
session_expiry: float,
62-
) -> str:
78+
) -> IssuedToken:
6379
"""Issue a Job credential that cannot outlive its provisioned session credential."""
64-
remaining = session_expiry - timezone.utcnow().timestamp()
65-
if remaining <= 0:
66-
raise ExpiredDagProcessorToken("Session credential has expired")
67-
return generator.generate(
80+
lifetime = _get_lifetime(generator, session_expiry, "Session credential has expired")
81+
token = generator.generate(
6882
extras={
6983
"sub": str(session_id),
7084
"scope": "dag_processor",
7185
"dag_bundles": sorted(bundle_names),
7286
"job_id": job_id,
7387
},
74-
valid_for=min(generator.valid_for, remaining),
88+
valid_for=lifetime,
7589
)
90+
return IssuedToken(token, lifetime)
7691

7792

7893
def generate_dag_parse_token(
@@ -84,12 +99,10 @@ def generate_dag_parse_token(
8499
bundle_name: str,
85100
relative_fileloc: str,
86101
processor_expiry: float,
87-
) -> str:
102+
) -> IssuedToken:
88103
"""Issue a parsing credential that cannot outlive its parent Job credential."""
89-
remaining = processor_expiry - timezone.utcnow().timestamp()
90-
if remaining <= 0:
91-
raise ExpiredDagProcessorToken("Processor credential has expired")
92-
return generator.generate(
104+
lifetime = _get_lifetime(generator, processor_expiry, "Processor credential has expired")
105+
token = generator.generate(
93106
extras={
94107
"sub": str(attempt_id),
95108
"scope": "dag_parse",
@@ -98,5 +111,6 @@ def generate_dag_parse_token(
98111
"dag_bundles": [bundle_name],
99112
"relative_fileloc": relative_fileloc,
100113
},
101-
valid_for=min(generator.valid_for, remaining),
114+
valid_for=lifetime,
102115
)
116+
return IssuedToken(token, lifetime)

‎airflow-core/src/airflow/api_fastapi/execution_api/datamodels/job.py‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -56,7 +56,8 @@ class JobRegisterResponse(StrictBaseModel):
5656
"""The registered Job and its management credential."""
5757

5858
job_id: int
59-
token: str = Field(description="A ``dag_processor`` token valid while the Job is open.")
59+
token: str = Field(description="A ``dag_processor`` token, valid until it expires or the Job ends.")
60+
expires_in: int = Field(gt=0, description="Seconds until the token expires.")
6061

6162

6263
class JobHeartbeatResponse(StrictBaseModel):
@@ -90,3 +91,4 @@ class DagParseTokenResponse(StrictBaseModel):
9091
"""Short-lived parsing credential; cannot register, heartbeat, or complete Jobs."""
9192

9293
token: str
94+
expires_in: int = Field(gt=0, description="Seconds until the token expires.")

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

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -109,3 +109,9 @@ class DagProcessorToken(ExecutionToken):
109109
"""Authenticated Dag processor Job."""
110110

111111
claims: DagProcessorClaims
112+
113+
114+
class DagParseToken(ExecutionToken):
115+
"""Authenticated parsing attempt."""
116+
117+
claims: DagParseClaims

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -65,7 +65,7 @@
6565
authenticated_router.include_router(variables.router, prefix="/variables", tags=["Variables"])
6666
authenticated_router.include_router(xcoms.router, prefix="/xcoms", tags=["XComs"])
6767
authenticated_router.include_router(hitl.router, prefix="/hitlDetails", tags=["Human in the Loop"])
68-
authenticated_router.include_router(jobs.router, prefix="/jobs", tags=["Jobs"])
68+
authenticated_router.include_router(jobs.router, tags=["Jobs"])
6969
authenticated_router.include_router(task_state_store.router, prefix="/store/ti", tags=["Task State Store"])
7070
authenticated_router.include_router(
7171
asset_state_store.router, prefix="/store/asset", tags=["Asset State Store"]

‎airflow-core/src/airflow/api_fastapi/execution_api/routes/connections.py‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -67,7 +67,8 @@ async def has_connection_access(
6767
dependencies=[ExecutionOrProcessorSecretsToken],
6868
responses={
6969
status.HTTP_401_UNAUTHORIZED: {"description": "Unauthorized"},
70-
status.HTTP_403_FORBIDDEN: {"description": "Task does not have access to the connection"},
70+
status.HTTP_400_BAD_REQUEST: {"description": "A Dag processor token did not name its Dag bundle"},
71+
status.HTTP_403_FORBIDDEN: {"description": "The caller does not have access to the connection"},
7172
},
7273
)
7374
def get_connection(

0 commit comments

Comments
 (0)