kaxil commented on code in PR #60108:
URL: https://github.com/apache/airflow/pull/60108#discussion_r3131873700


##########
airflow-core/src/airflow/api_fastapi/auth/tokens.py:
##########
@@ -418,6 +418,10 @@ class JWTGenerator:
 
     kid: str = attrs.field(default=attrs.Factory(_generate_kid, 
takes_self=True))
     valid_for: float
+    workload_valid_for: float = attrs.field(

Review Comment:
   `JWTGenerator` is used by auth managers (FAB, Simple) via 
`BaseAuthManager._get_token_signer`, not just the execution API. Adding a 
class-level field that unconditionally reads `[scheduler] task_queued_timeout` 
at construction couples a general auth primitive to scheduler config. In an 
api-server-only or auth-manager-only deployment, this pulls a key that's 
irrelevant to the caller and potentially absent.
   
   Can you move `workload_valid_for` out of `JWTGenerator` and compute it at 
the workload-generation site (e.g., in `BaseWorkloadSchema.generate_token`)? 
`generate()` already accepts a per-call `valid_for` override, so the generator 
doesn't need to know about this concept.



##########
airflow-core/src/airflow/api_fastapi/execution_api/routes/task_instances.py:
##########
@@ -230,6 +238,17 @@ def ti_run(
         last_heartbeat_at=timezone.utcnow(),
     )
 
+    execution_token: str | None = None

Review Comment:
   Token minting runs before the state-transition executes. If the state update 
fails (SQLAlchemyError at the `session.execute(query)` below, or the client 
hits the 409 path further down), you've minted a token that's thrown away.
   
   More importantly, in the duplicate-request branch earlier in this handler 
(where we return early for a TI already in RUNNING), a workload-token call 
still mints a new execution token even though the worker already has one from 
its first request.
   
   Can this block move down to just before `return context` so it only fires on 
the success path?



##########
airflow-core/tests/unit/api_fastapi/execution_api/versions/head/test_task_instances.py:
##########
@@ -281,6 +284,54 @@ def test_ti_run_state_to_running(
         )
         assert response.status_code == 409
 
+    def test_ti_run_returns_execution_token(
+        self, client, exec_app, session, create_task_instance, time_machine
+    ):
+        """PATCH /run with a workload token should swap to an execution-scoped 
token."""
+        instant = timezone.parse("2024-10-31T12:00:00Z")
+        time_machine.move_to(instant, tick=False)
+
+        ti = create_task_instance(
+            task_id="test_exec_token",
+            state=State.QUEUED,
+            dagrun_state=DagRunState.RUNNING,
+            session=session,
+            start_date=instant,
+            dag_id=str(uuid4()),
+        )
+        session.commit()
+
+        mock_gen = mock.MagicMock(spec=JWTGenerator)
+        mock_gen.generate.return_value = "mock-execution-token"
+        lifespan.registry.register_value(JWTGenerator, mock_gen)

Review Comment:
   `lifespan.registry.register_value(JWTGenerator, mock_gen)` registers into 
module-level state that persists past this test. I don't see a teardown that 
removes it, so later tests in the session will see this mock generator instead 
of the real one -- that's a hidden test-pollution source.
   
   Same pattern exists in `TestTokenTypeValidation` with `JWTValidator`.
   
   Can you add an autouse fixture (or `finally` block) that restores/clears the 
registry entry after each test? The PR description mentions "registry teardown 
to prevent test pollution" but I can't find the actual teardown in this file.



##########
airflow-core/src/airflow/api_fastapi/execution_api/routes/task_instances.py:
##########
@@ -230,6 +238,17 @@ def ti_run(
         last_heartbeat_at=timezone.utcnow(),
     )
 
+    execution_token: str | None = None
+    if token.claims.scope == "workload":
+        try:
+            generator: JWTGenerator = services.get(JWTGenerator)
+            execution_token = generator.generate(extras={"sub": 
str(task_instance_id), "scope": "execution"})
+        except (jwt.PyJWTError, ValueError, KeyError):

Review Comment:
   This catch is too broad. `generate()` is deterministic; 
`ValueError`/`KeyError` here would indicate a programming bug (bad extras dict, 
missing signing key) that should surface with a real stack trace, not a generic 
500.
   
   Also worth noting: `services.get(JWTGenerator)` raises 
`svcs.exceptions.ServiceNotFoundError`, which isn't in this tuple -- so if the 
registry is misconfigured you'll get an unhelpful 500 from the global handler 
anyway.
   
   Suggest narrowing to `jwt.PyJWTError` only, or dropping the try/except 
entirely and letting the global handler log the real error.



##########
airflow-core/src/airflow/api_fastapi/execution_api/app.py:
##########
@@ -333,10 +342,30 @@ async def always_allow(request: Request):
                 claims = TIClaims(scope="execution")
                 return TIToken(id=ti_id, claims=claims)
 
+            # Override _container (the svcs service locator behind 
DepContainer).
+            # The default _container reads request.app.state.svcs_registry, but
+            # Cadwyn's versioned sub-apps don't inherit the main app's state,
+            # so lookups raise ServiceNotFoundError. This registry provides
+            # services needed by routes called during dag.test().
+            # Note: tokens generated by this stub are never validated since
+            # _jwt_bearer is overridden with always_allow in dag.test() mode.
+            stub_generator = JWTGenerator(

Review Comment:
   The stub generator uses a per-process random secret 
(`secrets.token_urlsafe(32)`), so any token it signs is unverifiable against 
the real validator. The comment above says tokens aren't validated because 
`_jwt_bearer` is overridden, but `/run` now actively mints an execution token 
via `services.get(JWTGenerator).generate(...)` and returns it in 
`Refreshed-API-Token`.
   
   Under `dag.test()` the current workload branch is skipped because 
`always_allow` returns `scope="execution"`, but this is a fragile invariant. 
Any future path that triggers the workload-token swap (or any test simulating a 
workload token through the in-process path) would hand the client a token no 
validator can verify.
   
   Consider either reusing the real signing key from `get_signing_args()`, or 
not registering a `JWTGenerator` under in-process mode at all so callers fail 
loudly if they ever exercise that path.



##########
airflow-core/src/airflow/executors/workloads/base.py:
##########
@@ -76,7 +76,12 @@ class BaseWorkloadSchema(BaseModel):
 
     @staticmethod
     def generate_token(sub_id: str, generator: JWTGenerator | None = None) -> 
str:
-        return generator.generate({"sub": sub_id}) if generator else ""
+        if not generator:
+            return ""
+        return generator.generate(

Review Comment:
   Since `JWTGenerator` ideally shouldn't know about `task_queued_timeout`, 
this is the right place to read it:
   
   ```python
   valid_for = conf.getfloat("scheduler", "task_queued_timeout")
   return generator.generate(
       extras={"sub": sub_id, "scope": "workload"},
       valid_for=valid_for,
   )
   ```
   
   That also lets you drop `workload_valid_for` from `JWTGenerator` entirely.



##########
airflow-core/src/airflow/api_fastapi/execution_api/app.py:
##########
@@ -142,6 +145,12 @@ async def dispatch(self, request: Request, call_next):
                     validator: JWTValidator = await services.aget(JWTValidator)
                     claims = await validator.avalidated_claims(token, {})
 
+                    # Workload tokens are long-lived and meant to survive queue
+                    # wait times so avoid refreshing them. If avalidated_claims
+                    # raises for a workload token, the outer except handles it.
+                    if claims.get("scope") == "workload":
+                        return response
+
                     now = int(time.time())
                     validity = conf.getint("execution_api", 
"jwt_expiration_time")
                     refresh_when_less_than = max(int(validity * 0.20), 30)

Review Comment:
   The refresh-threshold denominator is `jwt_expiration_time`, which only makes 
sense for execution tokens. The new early-return at line 151 (`if 
claims.get("scope") == "workload": return response`) guards workload tokens 
today, but if someone removes that guard or adds a new scope with a different 
lifetime, this threshold math silently becomes wrong (e.g. comparing a 5-minute 
workload-token's remaining life against 20% of the 10-minute execution-token 
lifetime).
   
   Worth computing the threshold from the actual token lifetime instead:
   
   ```python
   token_lifetime = int(claims.get("exp", 0)) - int(claims.get("iat", 0))
   refresh_when_less_than = max(int(token_lifetime * 0.20), 30)
   ```



-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: [email protected]

For queries about this service, please contact Infrastructure at:
[email protected]

Reply via email to