ADR-004 — _protected_routers 익명 라우터 분리 표준 패턴
| 항목 | 값 |
|---|---|
| Status | Accepted (2026-05-24 — Epic #1002 구현) |
| Date | 2026-05-24 |
| Decider | GenD 코어팀 |
| Related Epic | #1002 (ADR-004), 자식 영향 #991 (A2A), #997 (Streamlit Publish) |
컨텍스트
GenD main.py:302-305 가 _protected_routers 의 모든 라우터에 router-level Depends(get_current_user) 강제 주입 (feedback_protected_routers_global_dep). 라우터 내부에서 get_optional_user 를 써도 우회 불가능 — auth/jwt_bearer.py 가 항상 401 raise.
새 RFP 갭 자식들이 익명 endpoint 를 요구:
- #991 A2A —
GET /.well-known/agent.json(외부 Agent 디스커버리) - #997 Streamlit viewer —
GET /pub/<slug>/meta(외부 카드 표시) - 반면 #996 admin dashboard 처럼 admin-only 는
_protected_routers적합
결정
옵션 A — 별도 router 객체 + app.include_router 분리 등록.
기존 GenD 패턴 (health / metrics / abuse_webhook / docs_chat / ingestion_internal / catalog.internal_router) 과 정합. 라우터 내부 get_optional_user 만으로는 우회 불가능하므로 별도 객체 분리만이 유일 해결.
표준 패턴
# apps/api/src/gend_api/routers/a2a.py
from fastapi import APIRouter
# 익명 허용 (외부 Agent 디스커버리)
public_router = APIRouter(prefix="", tags=["a2a-public"])
@public_router.get("/.well-known/agent.json")
async def get_agent_card():
return AGENT_CARD_SIGNED # signed JWS, 보안은 서명으로
# JWT 보호 (Skill 호출)
router = APIRouter(prefix="/api/v1/a2a", tags=["a2a"])
@router.post("/tasks")
async def create_task(req: TaskCreate, user: User = Depends(get_current_user)):
...
# apps/api/src/gend_api/main.py
# 익명 라우터 — JWT 보호 외부에 직접 등록
app.include_router(a2a.public_router)
# JWT 보호 라우터 — _protected_routers 리스트에 추가
_protected_routers = [
...,
a2a.router,
...,
]
명명 컨벤션
| 객체 | 의미 |
|---|---|
router (단수) | JWT 보호 — _protected_routers 리스트에 들어감 |
public_router | 익명 — app.include_router(...) 직접 등록 |
internal_router | service-to-service (X-Internal-Token) — 직접 등록 |
거부된 대안
(B) _protected_routers 내부 + 라우터별 익명 path skip 리스트
- 거부 사유: path 매칭이 fragile,
get_current_user가 항상 호출돼 의미 불명확, 신규 path 추가 시 skip 누락 위험.
(C) middleware 단 path 화이트리스트
- 거부 사유: 라우터 정의와 보호 정책이 분리돼 추적성 손실, middleware 변경 시 보안 회귀 위험.
영향
코드
apps/api/src/gend_api/main.py— ADR-004 컨벤션 주석 추가 (_protected_routers블록)- 향후 #991 A2A / #997 Streamlit Publish 가 패턴을 따라
public_router추가
회귀 가드 (신규)
tests/test_protected_routers_registration.py— 3 casestest_every_router_module_registered_in_main— 모든 라우터 모듈이 어딘가에 등록 (드리프트 차단)test_protected_routers_list_uses_router_attr_only— 익명/internal 라우터가_protected_routers에 섞이지 않음test_known_public_routers_registered_outside_protected— health/metrics 등 익명이어야 할 라우터가 protected 로 이동하지 않음
문서
- 본 ADR 파일 (단일 진실의 원천)
main.py의 인라인 컨벤션 주석
관련
- Epic: #1002 (ADR-004)
- 영향 자식: #991 (A2A
/.well-known/agent.json), #997 (Streamlit viewer/pub/<slug>/meta) - 메모리:
feedback_protected_routers_global_dep— 본 ADR 의 근본 원인 - 자매 ADR: ADR-006 Ontology Layered Packaging