← 학습 카테고리

Learn

Airflow

151개 모듈 · 현재 99번째

Airflow 모듈 99/151 airflow-learn-99

JWT 인증 (2) Execution API 스코프와 워크로드 격리 한계

Apache Airflow Official Documentation (in-repo snapshot) — Apache Software Foundation security/jwt_token_authentication.rst — Execution API Authentication Flow, Dag File Processor and Triggerer 섹션

이 모듈을 다 읽으면

  • workload 스코프와 execution 스코프 토큰의 수명, 발급 시점, 용도 차이를 설명할 수 있다
  • 스케줄러 → executor → 워커로 토큰이 전달되고 execution 스코프로 교체되는 과정을 순서대로 설명할 수 있다
  • Dag File Processor/Triggerer의 in-process 접근이 왜 완전한 격리를 보장하지 못하는지 판단할 수 있다

Execution API는 워커·Dag File Processor·Triggerer가 태스크 상태를 보고하고 런타임 데이터를 조회하는 데 쓰는 내부 API다. workload/execution 두 스코프로 토큰 수명을 분리해 큐 대기와 실행 중 통신을 다르게 다루며, 토큰 폐기 대신 짧은 수명과 자동 갱신에 의존한다. 다만 in-process로 동작하는 Dag File Processor와 Triggerer는 이 JWT 인프라 자체를 우회하기 때문에 별도의 격리 논의가 필요하다.

workload 스코프와 execution 스코프

Execution API 토큰의 claim은 jti, iss, aud(기본값 urn:airflow.apache.org:task), sub(태스크 인스턴스 UUID), scope, iat, nbf, exp로 구성된다. scope claim은 workload 또는 execution 두 값 중 하나를 가지며, scope claim이 없는 토큰은 하위 호환을 위해 execution으로 간주된다.

workload 토큰은 스케줄러가 워크로드 JSON 페이로드에 담아 태스크를 디스패치할 때 생성한다. 이 토큰의 수명은 [scheduler] task_queued_timeout(기본 600초)과 같은데, 이는 스케줄러가 큐에 막힌 태스크를 회수(reap)하는 데 쓰는 타임아웃과 동일한 값이다. 즉 task_queued_timeout을 늘리면 큐가 밀려도 태스크가 대기할 수 있는 시간이 늘어나는 동시에 workload 토큰이 유효한 창도 함께 넓어진다.

execution 스코프는 기본 10분의 짧은 수명을 가지며 태스크 실행 중 워커 통신의 표준 스코프다. 워커가 /run 엔드포인트를 workload 토큰으로 호출해 태스크를 RUNNING으로 전환하면, 서버는 이때 새로운 execution 스코프 토큰을 발급해 Refreshed-API-Token 응답 헤더로 내려준다.

핵심 포인트

  • workload 토큰은 스케줄러가 태스크 디스패치 시 생성하며 수명은 task_queued_timeout(기본 600초)과 같아 큐 대기 시간을 커버한다
  • 워커가 /run을 workload 토큰으로 호출하면 서버는 Refreshed-API-Token 헤더로 새 execution 토큰(기본 10분)을 내려준다
  • scope claim이 없는 토큰은 하위 호환을 위해 execution으로 간주된다

토큰 전달 경로와 제한된 공격 표면

토큰은 다음 순서로 실행 스택을 통과한다: (1) 스케줄러가 workload 스코프 토큰을 생성해 워크로드 JSON에 담아 executor에 전달, (2) 이 JSON이 Celery 메시지·쿠버네티스 Pod spec·로컬 서브프로세스 인자 등 executor별 방식으로 워커 프로세스에 전달, (3) 워커의 execute_workload() 함수가 워크로드 JSON에서 토큰을 추출, (4) supervise_task() 함수가 이 토큰으로 BearerAuth를 설정한 httpx.Client를 생성, (5) 워커가 workload 토큰으로 /run을 호출해 태스크를 RUNNING으로 표시하면 서버가 execution 스코프 토큰을 Refreshed-API-Token 헤더로 응답, (6) 클라이언트의 _update_auth() 훅이 이 헤더를 감지해 BearerAuth를 새 execution 토큰으로 투명하게 교체한다.

이 설계는 workload 토큰이 유출되더라도 공격 표면을 제한한다. workload 토큰으로는 /run만 호출할 수 있고, 이 엔드포인트는 태스크 인스턴스가 QUEUED 또는 RESTARTING 상태가 아니면 재실행 요청을 409 Conflict로 거부한다. 따라서 유출된 workload 토큰으로 할 수 있는 최악의 일은 '이미 큐에 있는 태스크를 시작시키는 것'으로 한정된다. 그 외 모든 엔드포인트는 scope=execution을 요구하며 workload 토큰을 거부한다.

핵심 포인트

  • 토큰은 스케줄러 → executor → 워커 → /run 호출 → execution 토큰으로 교체의 순서로 전달된다
  • workload 토큰이 유출되어도 /run 외에는 호출할 수 없고, QUEUED/RESTARTING 상태가 아니면 409로 거부되어 공격 표면이 '이미 큐에 있는 태스크 시작'으로 한정된다

토큰 검증과 라우트 수준 강제

JWTBearer 보안 의존성은 요청마다 한 번 토큰을 검증한다: Authorization: Bearer 헤더에서 토큰 추출 → JWTValidator로 서명 검증 → exp/iat/aud(설정 시 nbf/iss) 표준 claim 검증 → scope claim이 없으면 기본값 execution으로 설정 → TIClaims라는 타입이 지정된 Pydantic 스키마로 태스크 신원 claim을 검증한다.

TIClaims는 scope가 TokenScope로 선언된 값(execution 또는 workload) 중 하나인지 강제하고, 이어서 TIToken이 sub claim을 UUID 필드로 파싱해 UUID가 아닌 값을 거부한다. scope가 알 수 없는 값이거나 sub가 유효한 UUID가 아니면, 암호학적 서명 검증을 통과했더라도 403 Forbidden으로 거부된다. TIClaims는 extra="allow"로 설정되어 있어 auth manager가 코어 스키마를 건드리지 않고도 배포별 추가 claim을 덧붙일 수 있다 — 보안에 직결되는 필드만 타입이 지정된다.

라우트 수준 강제는 require_auth가 담당한다. 라우트 등록 시 ExecutionAPIRoute가 token:* Security 스코프에서 미리 계산해 둔 route.allowed_token_types와 토큰의 scope를 비교하고, ti:self 스코프가 선언된 라우트에서는 토큰의 sub claim이 경로 파라미터 {task_instance_id}와 일치하는지 검증해 한 워커가 다른 태스크의 엔드포인트에 접근하지 못하도록 막는다.

핵심 포인트

  • scope가 알 수 없는 값이거나 sub가 유효한 UUID가 아니면 서명 검증을 통과해도 403으로 거부된다(TIClaims/TIToken의 타입 검증)
  • require_auth는 라우트별 허용 스코프(allowed_token_types)와 ti:self 스코프(자기 자신의 task_instance_id만 접근 가능)를 강제한다

Execution API에는 토큰 폐기가 없다

Execution API 토큰은 폐기 대상이 아니다. execution 스코프 토큰은 짧은 수명(기본 10분)을 가지며 JWTReissueMiddleware가 자동으로 갱신하고, workload 스코프 토큰은 task_queued_timeout을 추적하는 수명으로 갱신 없이 자연 만료된다. 즉 Execution API의 보안 모델에는 폐기라는 개념 자체가 없으며, 대신 짧은 수명과 자동 갱신으로 위험을 관리한다.

JWTReissueMiddleware는 execution 스코프 토큰만 갱신 대상으로 삼는다. 응답이 나간 뒤 토큰의 남은 유효기간이 전체 유효기간의 20% 미만(최소 30초)이 되면 서버가 원래 claim(scope, sub 포함)을 그대로 유지한 새 토큰을 생성해 Refreshed-API-Token 헤더로 반환하고, 클라이언트의 _update_auth() 훅이 이를 감지해 BearerAuth를 교체한다. workload 스코프 토큰은 큐 대기 시간을 버티도록 설계되어 있어 이 미들웨어의 갱신 대상에서 명시적으로 제외된다.

핵심 포인트

  • Execution API 토큰은 폐기 메커니즘이 없고 대신 execution 토큰의 짧은 수명(기본 10분)과 자동 갱신에 의존한다
  • JWTReissueMiddleware는 execution 스코프 토큰만 갱신하며, 남은 유효기간이 20% 미만(최소 30초)일 때 갱신한다 — workload 토큰은 갱신 대상이 아니다

Dag File Processor·Triggerer의 in-process 우회와 그 한계

Dag File Processor(DFP)와 Triggerer는 네트워크가 아니라 in-process 전송(InProcessExecutionAPI)을 통해 Execution API와 통신한다. 이 방식은 ASGI/WSGI 브리지를 이용해 같은 프로세스 안에서 Execution API 애플리케이션을 직접 실행하며, JWT bearer 의존성을 오버라이드해 항상 execution 스코프의 합성(synthetic) TIToken을 반환함으로써 토큰 검증 자체를 사실상 우회한다. 커넥션·변수·XCom에 대한 리소스별 접근 제어 역시 항상 허용하도록 오버라이드되어 함께 우회된다.

Airflow는 이 컴포넌트들 안에서 Dag 작성자 코드가 실수로 데이터베이스에 직접 접근하는 것을 막는 소프트웨어 가드를 두고 있다. 그러나 Dag 파일을 파싱하거나 트리거 코드를 실행하는 자식 프로세스가 부모 프로세스와 동일한 Unix 사용자로 실행되기 때문에, 이 가드는 의도적인 접근까지는 막지 못한다. 악의적인 Dag 작성자는 /proc/<PID>/environ, 설정 파일, 시크릿 매니저 접근 등을 통해 부모 프로세스의 데이터베이스 자격증명을 탈취해 유효한 JWT 없이도 메타데이터 DB와 모든 Execution API 작업에 대한 완전한 읽기/쓰기 권한을 얻을 수 있다. 이는 워커/태스크 실행 쪽과 대비된다 — 그쪽은 배포 단에서 격리가 구현되어, 민감한 DB 자격증명이 애초에 워커 프로세스의 배포 설정에 존재하지 않고 오직 Execution API를 통해서만 통신한다.

기본 배포에서는 단일 DFP 인스턴스가 모든 팀의 Dag 파일을 파싱하고, 단일 Triggerer 인스턴스가 모든 팀의 트리거를 처리한다. 즉 서로 다른 팀의 Dag 작성자 코드가 같은 프로세스 안에서 실행되며, in-process Execution API와 메타데이터 DB에 대한 접근을 잠재적으로 공유한다. 격리가 필요한 멀티팀 배포에서는 Deployment Manager가 팀별로 별도의 DFP·Triggerer 인스턴스를 운영해야 한다 — Airflow는 팀별 DFP/Triggerer를 위한 내장 지원을 제공하지 않는다. 별도 인스턴스를 두더라도 각 인스턴스는 여전히 부모와 같은 Unix 사용자를 쓰므로, 자격증명 탈취를 막으려면 Unix 사용자 수준 격리(자식 프로세스를 다른 저권한 사용자로 실행)나 네트워크 수준 제한을 배포 단에서 추가로 구현해야 한다.

핵심 포인트

  • DFP와 Triggerer는 in-process 전송을 사용해 JWT 검증과 리소스별 접근 제어를 모두 우회하며, 항상 execution 스코프의 합성 TIToken을 반환한다
  • 자식 프로세스가 부모와 동일한 Unix 사용자로 실행되므로, 소프트웨어 가드는 실수는 막아도 악의적인 Dag 작성자의 의도적 자격증명 탈취(/proc/<PID>/environ 등)는 막지 못한다
  • 기본 배포에서는 모든 팀이 단일 DFP/Triggerer 인스턴스를 공유하며, Airflow는 팀별 인스턴스 분리를 내장 지원하지 않는다
  • 멀티팀 격리를 위해서는 팀별 DFP/Triggerer 분리에 더해 Unix 사용자 격리나 네트워크 수준 제한을 배포 단에서 직접 구현해야 한다