Skip to content

Commit a73ffae

Browse files
authored
Add scoped Execution API access for Dag processors (#74370)
* Add scoped Execution API access for Dag processors Dag processors need credentials tied to their registered Job and granted bundles before they can communicate with a remote Execution API. Keep this foundation independently reviewable while the processor command retains its existing database-backed behavior. * Fix documentation build for task-token revocation The heading warning prevents the security documentation from building successfully. * Allow parsing-time queries before a Dag is first stored New Dags can query execution state while parsing, before they have a DagModel row. Rejecting those requests prevents their initial parse from completing. * Enforce scoped Dag processor token claims and expiry Task and processor credentials need distinct validation so task-only routes cannot receive processor identities. Expired provisioning credentials must not leave a registered Job behind, while trusted in-process task identities must remain usable without JWT expiry. * 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. * Fix Dag processor token docs and renewal test flakiness Slow CI responses can exceed the best-effort renewal deadline, while the authentication guidance must reflect the scoped Execution API contract.
1 parent 2212818 commit a73ffae

45 files changed

Lines changed: 5054 additions & 145 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

‎airflow-core/docs/migrations-ref.rst‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,9 @@ Here's the list of all the Database Migrations that are executed via when you ru
3939
+-------------------------+------------------+-------------------+--------------------------------------------------------------+
4040
| Revision ID | Revises ID | Airflow Version | Description |
4141
+=========================+==================+===================+==============================================================+
42-
| ``a5d7b9c13e40`` (head) | ``c3e7a9182f64`` | ``3.4.0`` | Preserve exact task execution attribution in asset state. |
42+
| ``c71065a1b14f`` (head) | ``a5d7b9c13e40`` | ``3.5.0`` | Add session_id and registration_id to Job. |
43+
+-------------------------+------------------+-------------------+--------------------------------------------------------------+
44+
| ``a5d7b9c13e40`` | ``c3e7a9182f64`` | ``3.4.0`` | Preserve exact task execution attribution in asset state. |
4345
+-------------------------+------------------+-------------------+--------------------------------------------------------------+
4446
| ``c3e7a9182f64`` | ``7f8c9a2d410e`` | ``3.4.0`` | Attribute log and asset events to exact task tries. |
4547
+-------------------------+------------------+-------------------+--------------------------------------------------------------+

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

Lines changed: 105 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -254,20 +254,21 @@ Token structure (Execution API)
254254
* - ``aud``
255255
- Audience (from ``[execution_api] jwt_audience``, default: ``urn:airflow.apache.org:task``).
256256
* - ``sub``
257-
- Task instance UUID — the identity of the workload.
257+
- UUID identifying the task instance, callback, processor session, or parsing attempt, depending on the scope.
258258
* - ``scope``
259-
- Token scope: ``"execution"`` or ``"workload"``.
259+
- Token scope: ``"execution"``, ``"workload"``, ``"callback"``, ``"dag_processor_session"``,
260+
``"dag_processor"``, or ``"dag_parse"``.
260261
* - ``iat``
261262
- Issued-at timestamp.
262263
* - ``nbf``
263264
- Not-before timestamp.
264265
* - ``exp``
265-
- Expiration timestamp (``iat + [execution_api] jwt_expiration_time``).
266+
- Expiration timestamp, determined by the scope's lifetime and any parent credential's expiry.
266267

267268
Token scopes (Execution API)
268269
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
269270

270-
The Execution API defines two token scopes with different lifetimes:
271+
The Execution API uses the following token scopes. Each endpoint accepts only its declared scopes:
271272

272273
**workload**
273274
A token embedded in the workload JSON payload when the Scheduler
@@ -281,12 +282,28 @@ The Execution API defines two token scopes with different lifetimes:
281282
backed-up queue before its workload token expires.
282283

283284
**execution**
284-
A short-lived token (default 10 minutes) accepted by all Execution API endpoints.
285+
A short-lived token (default 10 minutes) accepted by endpoints that allow task or callback execution.
285286
This is the standard scope for worker communication during task execution. Issued
286287
by the server when the worker transitions to running via the ``/run`` endpoint.
287288
The ``JWTReissueMiddleware`` refreshes ``execution`` tokens transparently,
288289
so the worker maintains access for the duration of the task.
289290

291+
**callback**
292+
A single-use token exchanged at ``/callbacks/{callback_id}/run`` for an ``execution``
293+
token when a queued callback starts.
294+
295+
**dag_processor_session**
296+
A provisioned credential accepted only by ``POST /jobs`` for Job registration and renewal.
297+
298+
**dag_processor**
299+
A Job credential for heartbeats, completion, Connection and Variable reads for granted bundles,
300+
and parsing-token exchange.
301+
302+
**dag_parse**
303+
A credential for one parsing attempt, granting parse-time access within its signed bundle.
304+
305+
See `Dag processor HTTP client credentials`_ for provisioning, lifetime limits, and authorization details.
306+
290307
Tokens without a ``scope`` claim default to ``"execution"`` for backwards compatibility.
291308

292309
Token delivery to workers
@@ -337,7 +354,7 @@ The token flows through the execution stack as follows:
337354
Even if a workload token is intercepted in transit, it can only call ``/run``. That endpoint
338355
rejects re-runs (``409 Conflict`` unless the task instance is in ``QUEUED`` or ``RESTARTING``),
339356
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.
357+
queued". Every other endpoint rejects workload tokens.
341358

342359
Token validation (Execution API)
343360
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
@@ -348,15 +365,17 @@ The ``JWTBearer`` security dependency validates the token once per request:
348365
2. Performs cryptographic signature validation via ``JWTValidator``.
349366
3. Verifies standard claims (``exp``, ``iat``, ``aud`` — ``nbf`` and ``iss`` if configured).
350367
4. Defaults the ``scope`` claim to ``"execution"`` if absent.
351-
5. **Validates the task identity claims against a typed Pydantic schema.** ``TIClaims``
352-
(in ``airflow.api_fastapi.execution_api.datamodels.token``) enforces that ``scope`` is one
353-
of the declared ``TokenScope`` literals (``"execution"`` or ``"workload"``); ``TIToken``
354-
then parses the ``sub`` claim through a ``UUID`` field, which rejects non-UUID values.
355-
A token whose ``scope`` is unknown, or whose ``sub`` is not a valid UUID, is rejected with
356-
``403 Forbidden`` even when the cryptographic signature checks pass. ``TIClaims`` keeps
357-
``extra="allow"`` so auth managers can attach additional, deployment-specific claims
358-
without modifying the core schema; only the security-critical fields are typed.
359-
6. Creates a ``TIToken`` object with the task instance ID and validated claims.
368+
5. **Validates the identity and its scope-specific claims against typed Pydantic schemas.**
369+
``ExecutionToken`` (in ``airflow.api_fastapi.execution_api.datamodels.token``) parses ``sub``
370+
as a UUID. Its claims form a discriminated union selected by ``scope``: ``TIClaims`` for
371+
``"execution"``, ``"workload"``, and ``"callback"``; ``DagProcessorSessionClaims`` for
372+
provisioning; ``DagProcessorClaims`` for a registered Job; and ``DagParseClaims`` for a
373+
parsing attempt. Each processor claim type requires expiry and its scope-specific grants
374+
and identity fields. Unknown scopes, invalid subjects, or missing required claims return
375+
``403 Forbidden`` even when cryptographic validation succeeds. All claim types allow extra
376+
fields so auth managers can attach deployment-specific claims.
377+
6. Constructs the corresponding token object: ``TIToken``, ``DagProcessorSessionToken``,
378+
``DagProcessorToken``, or ``DagParseToken``.
360379
7. Caches the validated token on the ASGI request scope for the duration of the request.
361380

362381
Route-level enforcement is handled by ``require_auth``:
@@ -367,26 +386,32 @@ Route-level enforcement is handled by ``require_auth``:
367386
``{task_instance_id}`` path parameter, preventing a worker from accessing another task's
368387
endpoints.
369388

389+
The task-only, processor-session, and processor-Job dependencies additionally reject tokens of
390+
the wrong principal type. Endpoints shared by tasks and parsing attempts use the general
391+
``CurrentExecutionToken`` dependency with their declared route scopes.
392+
370393
.. mermaid::
371394

372395
flowchart TD
373396
REQ([Incoming request<br/>Authorization: Bearer ...])
374397
REQ --> CACHE{Cached on<br/>request.scope?}
375-
CACHE -->|yes| RET([Return cached TIToken])
398+
CACHE -->|yes| RET([Return cached token])
376399
CACHE -->|no| SIG[JWTValidator:<br/>verify signature]
377400
SIG -->|fail| F1([403 Forbidden])
378401
SIG -->|ok| STD[Verify exp / iat / nbf<br/>aud / iss]
379402
STD -->|fail| F1
380403
STD -->|ok| SCOPE[Default scope to<br/>'execution' if absent]
381-
SCOPE --> SCHEMA[TIClaims:<br/>typed Pydantic schema]
404+
SCOPE --> SCHEMA[ExecutionToken:<br/>UUID identity and<br/>scope-discriminated claims]
382405
SCHEMA -->|ValidationError| F1
383406
SCHEMA -->|ok| TYP{require_auth:<br/>scope in<br/>route.allowed_token_types?}
384407
TYP -->|no| F1
385408
TYP -->|yes| SELF{ti:self scope<br/>declared?}
386-
SELF -->|no| OK([Return TIToken])
409+
SELF -->|no| PRINCIPAL{Typed dependency:<br/>principal type matches?}
387410
SELF -->|yes| MATCH{token.sub ==<br/>task_instance_id?}
388411
MATCH -->|no| F1
389-
MATCH -->|yes| OK
412+
MATCH -->|yes| PRINCIPAL
413+
PRINCIPAL -->|no| F1
414+
PRINCIPAL -->|yes or general dependency| OK([Return validated token])
390415

391416
classDef fail fill:#ffcdd2,stroke:#c62828,stroke-width:2px,color:#000
392417
classDef pass fill:#c8e6c9,stroke:#2e7d32,stroke-width:2px,color:#000
@@ -412,14 +437,14 @@ they are designed to survive executor queue wait times without needing refresh.
412437
ensures long-running tasks do not lose API access without requiring the worker to
413438
re-authenticate.
414439

415-
No token revocation (Execution API)
416-
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
440+
Task-token revocation (Execution API)
441+
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
417442

418-
Execution API tokens are not subject to revocation. ``execution``-scoped tokens are short-lived
443+
Task Execution API tokens are not subject to revocation. ``execution``-scoped tokens are short-lived
419444
(default 10 minutes) and automatically refreshed by the ``JWTReissueMiddleware``.
420445
``workload``-scoped tokens (tracking ``[scheduler] task_queued_timeout``) are not refreshed —
421-
they expire naturally after their validity period. Revocation is not part of the Execution API
422-
security model.
446+
they expire naturally after their validity period. Processor and parsing credentials additionally
447+
require an open, owning Job, as described under "Dag processor HTTP client credentials" below.
423448

424449

425450

@@ -486,6 +511,62 @@ See :doc:`/security/security_model` for the full security implications, deployme
486511
guidance, and the planned strategic and tactical improvements.
487512

488513

514+
Dag processor HTTP client credentials
515+
-------------------------------------
516+
517+
The core ``DagProcessorAPIClient`` lets processor integrations authenticate to the Execution
518+
API without holding its signing key. The standard ``airflow dag-processor`` command does
519+
not yet use this client. Provisioning a token file alone does not switch that command to
520+
HTTP or remove its need for database access.
521+
522+
On a trusted host with the API signing key, provision a session token for the bundles
523+
the client may access::
524+
525+
airflow dag-processor-token --token-file /run/airflow/processor.jwt \
526+
--bundle-name dags-folder --rotate
527+
528+
The command writes the token atomically. A new file is readable only by its owner, and replacing
529+
an existing file keeps its mode and group, so grant the processor read access once. ACLs are not
530+
carried over. Keep the provisioner running and mount its directory rather than a single file so
531+
token replacement stays visible. Each processor process needs its own session. Restarting the
532+
provisioner with the same token file and bundles keeps that session, so running processors keep
533+
their Jobs.
534+
535+
The three credentials have different issuers and permissions:
536+
537+
- ``dag_processor_session`` is issued by trusted provisioning. It can only register a
538+
Job or renew that Job's credential through ``POST /jobs``.
539+
- ``dag_processor`` is returned by Job registration. It can heartbeat and complete that
540+
Job, read Connections and Variables for a granted bundle, and exchange a parsing
541+
credential through ``POST /jobs/{job_id}/parse-token``. It cannot outlive the session
542+
token used to obtain it.
543+
- ``dag_parse`` is returned by the parsing-token exchange for one attempt, bundle, and
544+
relative file location. It permits parse-time requests, including callback-context reads,
545+
but cannot manage Jobs or exchange tokens. It cannot outlive its parent Job credential.
546+
547+
Registration retries keep the same registration UUID. A new process uses a new UUID;
548+
it cannot replace a session's Job while that Job is alive. Ending or replacing the Job
549+
invalidates its runtime credentials.
550+
551+
The client selects the bundle with ``use_bundle`` and a parsing attempt with ``use_parse``.
552+
A request header cannot override the parsing token's signed bundle.
553+
Connection and Variable access uses the bundle's current team. A registered Job's teams come from
554+
``[dag_processor] dag_bundle_config_list`` on the API server, so it needs the same bundle-to-team
555+
configuration as the processor. A ``dag_parse`` token can also write and delete Variables, as a
556+
parsing subprocess can without this client. ``Variable.set`` upserts on the key alone, so in
557+
multi-team mode a write can overwrite another team's Variable with the same key, as it can with task
558+
tokens.
559+
560+
The client attempts renewal at 80% of token lifetime. Transient early-renewal failures leave
561+
the existing token usable until expiry. A retired registration signals that its owner must
562+
restart. Completion retains its original identity and outcome across retries.
563+
564+
File and attempt identity are carried in the token but are not currently used for access
565+
checks or request attribution. Archives retain one file identity under the current processor
566+
model. These credentials do not isolate hostile code from other credentials accessible on
567+
the processor host; the process and deployment limitations above still apply.
568+
569+
489570
Workload Isolation and Current Limitations
490571
------------------------------------------
491572

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

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -532,10 +532,11 @@ potentially still executes with direct database access in the Dag File Processor
532532
* **Tightened deserialization allowlist.** The serializer's class-name allowlist regex is
533533
anchored to require a full-string match, so attacker-controlled values cannot smuggle
534534
untrusted class names by appending an allowed suffix to a non-allowed name.
535-
* **Typed JWT claims schema.** Even after cryptographic validation, claims are run through a
536-
typed Pydantic schema (``TIClaims``) that enforces the ``scope`` literal, and then through
537-
``TIToken`` which parses ``sub`` as a UUID. A token whose ``scope`` is unknown or whose
538-
``sub`` is not a valid UUID is rejected before any route handler runs.
535+
* **Typed JWT claims schemas.** Even after cryptographic validation, ``ExecutionToken`` validates
536+
``sub`` as a UUID and selects a claims schema by ``scope``: ``TIClaims`` for task, workload,
537+
and callback credentials, or ``DagProcessorSessionClaims``, ``DagProcessorClaims``, or
538+
``DagParseClaims`` for processor credentials. Unknown scopes, invalid subjects, and missing
539+
required scope-specific claims are rejected before any route handler runs.
539540

540541
**Token signing key might be a shared secret**
541542
In symmetric key mode (``[api_auth] jwt_secret``), the same secret key is used to both generate and

‎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"`), route-level enforcement via `ExecutionAPIRoute` + `require_auth`, and the `ti:self` path-parameter validation are documented in the module docstring of `security.py`.
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: 10 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,7 @@
5353
__all__ = [
5454
"InProcessExecutionAPI",
5555
"create_task_execution_api_app",
56+
"create_jwt_generator",
5657
"lifespan",
5758
"CorrelationIdMiddleware",
5859
]
@@ -74,7 +75,8 @@ def _jwt_validator():
7475
return validator
7576

7677

77-
def _jwt_generator():
78+
def create_jwt_generator() -> JWTGenerator:
79+
"""Create a signer using the Execution API's configured key, audience, and lifetime."""
7880
from airflow.configuration import conf
7981

8082
generator = JWTGenerator(
@@ -96,7 +98,7 @@ async def lifespan(app: FastAPI, registry: svcs.Registry):
9698
# record this here
9799
app.state.svcs_registry = registry
98100

99-
registry.register_factory(JWTGenerator, _jwt_generator)
101+
registry.register_factory(JWTGenerator, create_jwt_generator)
100102

101103
# InProcessExecutionAPI stubs out JWTValidator: don't re-register in that case.
102104
if JWTValidator not in registry:
@@ -146,10 +148,12 @@ async def dispatch(self, request: Request, call_next):
146148
validator: JWTValidator = await services.aget(JWTValidator)
147149
claims = await validator.avalidated_claims(token, {})
148150

149-
# Workload and callback tokens are long-lived and meant to survive
150-
# queue wait times so avoid refreshing them. If avalidated_claims
151-
# raises for such a token, the outer except handles it.
152-
if claims.get("scope") in ("workload", "callback"):
151+
# Only short-lived execution tokens are renewed here. Any other type has a
152+
# lifetime set by its issuer (workload and callback tokens outlive queue waits,
153+
# the dag_processor_session token is rotated by provisioning, and Job and parsing
154+
# tokens are renewed by re-registering and re-exchanging), so a new type must
155+
# not become renewable by default. Tokens without a scope are legacy execution tokens.
156+
if claims.get("scope", "execution") != "execution":
153157
return response
154158

155159
now = int(time.time())

0 commit comments

Comments
 (0)