From 44b68e8625730b96e0a66484d31af4fe59ff490e Mon Sep 17 00:00:00 2001 From: dldnsgkr <109467940+dldnsgkr@users.noreply.github.com> Date: Mon, 5 Oct 2026 15:07:40 +0900 Subject: [PATCH 1/3] =?UTF-8?q?docs:=2010-05=20=EB=A6=B4=EB=A6=AC=EC=8A=A4?= =?UTF-8?q?=20=ED=98=84=ED=99=A9=EA=B3=BC=20=EC=84=B8=EC=85=98=20=EC=9D=B8?= =?UTF-8?q?=EA=B3=84=EB=A5=BC=20=EC=A0=95=EB=A6=AC=ED=95=9C=EB=8B=A4=20[?= =?UTF-8?q?=20#429=20]=20(#450)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit §0 이 세 릴리스만큼 뒤처져 있었다. develop == main 이고 오늘 다섯 번 나갔다. ROADMAP 에 다음 세션이 볼 것을 남겼다 — 코드로 할 것은 비어 있고, 남은 셋은 사용자 손(설치 재승인·Cloudflare)과 측정 선행(#344 9-2)이다. #413 토큰 전환은 하지 않기로 결론났다. 오늘 네 번 나온 "전제가 틀렸다" 도 적었다. #332·#344·#154 는 끝난 일을 안 끝났다고 기록하고 있었고 #413 은 축소 대상이 엉뚱했다. 전부 이슈·문서를 먼저 읽고 코드를 나중에 본 탓이고, #154 는 그 때문에 사용자에게 불필요한 결정을 물었다. Co-authored-by: Claude Opus 5 (1M context) --- .notion/ROADMAP.md | 6 +++++- docs/pending-actions.md | 13 ++++++------- 2 files changed, 11 insertions(+), 8 deletions(-) diff --git a/.notion/ROADMAP.md b/.notion/ROADMAP.md index 07b8d005..89e0aa0e 100644 --- a/.notion/ROADMAP.md +++ b/.notion/ROADMAP.md @@ -10,7 +10,11 @@ ## 1. 현재 상태 -- 2026-10-04: **SSE 끊김 ERROR·docker-java 핀·App 권한 실측을 운영에 올리고, 자격 실패가 조용히 묻히던 것을 고쳤다 — main 배포 완료(#423 #425 #414 → PR #436 `90338cd`, #429 → PR #439 `cb00d87`). **미릴리스 없음**. +- 2026-10-04: **SSE 끊김 ERROR·docker-java 핀·App 권한 실측을 운영에 올리고, 자격 실패가 조용히 묻히던 것을 고쳤다 — main 배포 완료 — PR #436 `90338cd`(#423 #425 #414) · #439 `cb00d87`(#429 B안) · #444 `31d2d0f5`(#429 A안) · #447 `0ad2b0d1`(배포 경로 감지) · #449 `b221d137`(문서). **미릴리스 없음**. + + **다음 세션이 볼 것**: 코드로 할 것은 비어 있다. 남은 셋은 ①계정 `unhakjace` 설치 재승인/제거(사용자) ②Cloudflare 라우팅 — 장애 아니고 품질 문제(`colo=LAX`·`loc=KR`, 사용자) ③`#344` 9-2(측정 선행, 권하지 않음). `#413` 토큰 전환은 **하지 않기로** 결론났다(`docs/pending-actions.md` §0-1). + + **오늘 네 번 "전제가 틀렸다" 가 나왔다** — `#332`·`#344`·`#154` 는 끝난 일을 안 끝났다고 기록하고 있었고, `#413` 은 축소 대상이 엉뚱했다(컨테이너는 사용자 OAuth 토큰을 쓴다). 전부 **이슈·문서를 먼저 읽고 코드를 나중에 본** 탓이고, `#154` 는 그 때문에 사용자에게 불필요한 결정을 물었다. **이슈·문서보다 코드가 먼저다.** **#423** SSE 를 보던 브라우저가 떠나면 `AsyncRequestNotUsableException` 이 catch-all 에 걸려 `ERROR Unexpected error` 로 남았다. 반환형 `void` 가 핵심이다 — 이 예외는 "응답을 더 쓸 수 없다" 는 뜻이라 `ResponseEntity` 를 돌려주면 죽은 응답에 쓰려다 2차 실패를 낸다. **결함이 로그 레벨이므로 테스트도 로그를 본다**(`ListAppender`). 그리고 단위 테스트가 증명하지 못하는 것이 하나 남아 있었다 — 죽은 응답에 정말 아무것도 쓰지 않는가. 죽은 응답은 목으로 만들 수 없어서 **실제 Tomcat 에 소켓을 붙이고 `SO_LINGER=0` 으로 RST** 를 보냈다(`ClientDisconnectRealSocketIntegrationTest`). 되돌림 세 방향 전부 확인. **dev 에서 "ERROR 0" 은 증거가 아니었다.** 기저율이 11시간에 2건이고 둘 다 FE 가 앱을 쓰는 창 안이었다 — 끊김이 아예 없었던 것과 구별되지 않는다. dev 에 매달리다 접었고, CI 안에서 영구히 도는 쪽이 더 나은 자리였다. diff --git a/docs/pending-actions.md b/docs/pending-actions.md index 6a2c5a89..b27f752b 100644 --- a/docs/pending-actions.md +++ b/docs/pending-actions.md @@ -9,18 +9,17 @@ --- -## 0. 릴리스 현황 — 2026-10-04 기준 +## 0. 릴리스 현황 — 2026-10-05 기준 **미릴리스 없음** — `develop == main`. | 릴리스 | 무엇 | 검증 | |---|---|---| -| PR #439 `cb00d87` | `#429` 자격 실패를 연결 상태로 반영 | dev 실측(`판정근거=ExpiredToken`, 매시간 WARN 0건) · 되돌림 3방향 · 헬스 200×5 · 카탈로그 200 | -| PR #436 `90338cd` | `#423` SSE 끊김을 ERROR 로 남기지 않음 · `#425` docker-java 핀 보호 · `#414` App 권한 실측 | 운영 코드 변경은 핸들러 하나(비주석 diff 직접 확인) · 되돌림 3방향 · 헬스 200×5 | -| PR #422 `340e8a7` | `#413` 컨테이너 git 자격 env 전환 · `#414` 문서 · `#415` 배포 스코프 누출 · `#421` | dev 합동 검증(FE) · 프리뷰 SHA 교체 · 감사 29→30 · 자격 파일 0건 | -| PR #408 `84444b4` | `#405` 이력의 죽은 preview 링크 제거 (**V66**) | 기준값 6개 일치 · 감소량 936 = 8×117 · 거짓 양성 79번 온전 | -| PR #404 `bdb0967` | `#392` 3단계 + `#401` | 운영 계약 4항목 일치 · ERROR 0 · Flyway 65 그대로 | -| PR #400 `3fa24a6` | `#395` 카탈로그 내구성 + 스킴 검증 (V65) | 스냅샷 행 기록 · 복원 조건 4개 통과 | +| PR #449 `b221d137` | `#413` 토큰 축소 분석(문서만) | 헬스 200 · 카탈로그 200 | +| PR #447 `0ad2b0d1` | `#429` 배포 경로 자격 실패 감지 + `INVALID_CREDENTIAL` · `#414` probe 개선 | **OpenAPI `errorCode` 에 `INVALID_CREDENTIAL` 확인**(새 jar 반영 증거) · 되돌림 2방향 | +| PR #444 `31d2d0f5` | `#429` A안 임시 자격 거부 · `#414` 축소 기록 · `#154` 정리 | **OpenAPI `sessionToken` 설명 확인** · 되돌림 2방향(셋째는 구별 불가 — 기록해 둠) | +| PR #439 `cb00d87` | `#429` B안 자격 실패를 상태로 반영 | dev 실측 `판정근거=ExpiredToken` · 매시간 WARN 0건 | +| PR #436 `90338cd` | `#423` SSE 끊김 · `#425` docker-java 핀 보호 · `#414` 권한 실측 | 운영 코드 변경은 핸들러 하나 · 되돌림 3방향 | **확인 방법**: `git log --oneline origin/main..origin/develop` — 비어 있으면 미릴리스 없음. From b94242795e507495de4cbbb8ef012f2fb9966f08 Mon Sep 17 00:00:00 2001 From: dldnsgkr <109467940+dldnsgkr@users.noreply.github.com> Date: Thu, 15 Oct 2026 00:20:34 +0900 Subject: [PATCH 2/3] =?UTF-8?q?docs(agent-tools):=20npm=20=EB=AF=B8?= =?UTF-8?q?=EB=B0=B0=ED=8F=AC=EB=A5=BC=20=EC=82=AC=EC=9A=A9=EB=B2=95=20?= =?UTF-8?q?=EC=95=9E=EC=97=90=20=EC=A0=81=EA=B3=A0=20=EB=82=A1=EC=9D=80=20?= =?UTF-8?q?=EC=95=88=EB=82=B4=EB=A5=BC=20=EA=B3=A0=EC=B9=9C=EB=8B=A4=20[?= =?UTF-8?q?=20#451=20]=20(#452)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit MCP·CLI 가 실사용 불가 상태였다. @qeploy/client·mcp·cli 가 npm 에 없는데 (레지스트리 404) README 의 사용법이 npx @qeploy/... 를 되는 것처럼 안내했고, FE 의 PAT 화면이 그걸 그대로 옮겼다. README 는 배포 보류를 의도적으로 적고 있었다("오픈소스 공개를 결정할 때") — 의도와 사용법 문단이 서로 어긋나 있었다. README 사용법 앞에 미배포 경고와 소스에서 실행하는 방법을 넣었다. 그 명령은 실제로 돌려 확인했다 — MCP 서버가 initialize 에 qeploy 0.1.0 으로 답하고 tools/list 로 읽기 도구 11개를 낸다(쓰기 도구는 QEPLOY_ENABLE_WRITES 없이는 숨는다, 설계대로). 처음 확인할 때 출력이 비어 서버 문제로 읽을 뻔했는데 macOS 에 timeout 명령이 없어서였다 — node 로 직접 대화해 다시 쟀다. 스코프 없는 npm "qeploy" 는 남의 패키지(중국 Qiniu 배포 CLI, 2018)라는 것도 적었다. 어디에도 그 이름을 안내하면 안 된다. 함께 고친 것: - IssueApiTokenRequest.expiresInDays Swagger 설명이 스코프 무관하게 "생략 시 90일, 최대 365일" 이었다. WRITE 는 기본 30·최대 90 이다. FE 는 이 설명을 따르지 않고 실제 코드에 맞게 짰다 - FRONTEND_API_GUIDE 가 FE 정본을 Dvely_FE_test 로 적고 있었다. 그 저장소는 7-19 이후 멈춰 있고 정본은 Dvely_FE 다. API_TEST_FLOW.md 는 구 저장소에만 있어 경로는 두고 "정본" 표현만 고쳤다 - 설계 문서 상태줄에 "npm 미배포 — 실사용 불가" 를 넣었다 Co-authored-by: Claude Opus 5.5 --- agent-tools/README.md | 14 ++++++++++++++ docs/FRONTEND_API_GUIDE.md | 6 +++--- docs/qeploy-mcp-cli-design.md | 2 +- .../dto/request/IssueApiTokenRequest.java | 4 +++- 4 files changed, 21 insertions(+), 5 deletions(-) diff --git a/agent-tools/README.md b/agent-tools/README.md index 58faaaa9..3d91c3f0 100644 --- a/agent-tools/README.md +++ b/agent-tools/README.md @@ -17,6 +17,20 @@ API 와 함께 두고 반복하기 위해서다. 자립적인 디렉터리라 공개 저장소로 떼어내는 것은 나중에 언제든 가능하고, 그 시점은 오픈소스 공개를 결정할 때다. +## ⚠️ 아직 npm 에 배포되지 않았다 + +`@qeploy/client`·`@qeploy/mcp`·`@qeploy/cli` 는 **레지스트리에 없다**(위 "지금은 이 저장소 안에 있다" 의 결과다). 그래서 아래의 `npx @qeploy/...` 명령은 **지금 그대로는 npm 404 로 실패한다** — 배포하면 그대로 동작할 형태로 적어 둔 것이다(#451). + +그때까지 쓰려면 소스에서 실행한다: + +```bash +cd agent-tools && npm install +claude mcp add qeploy -- node "$(pwd)/packages/mcp/src/index.js" +node packages/cli/src/bin.js login +``` + +**스코프 없는 npm `qeploy` 는 우리 것이 아니다**(남의 패키지, 2018). 어디에도 그 이름을 안내하지 않는다. + ## 시작하기 — 로그인 한 번 ```bash diff --git a/docs/FRONTEND_API_GUIDE.md b/docs/FRONTEND_API_GUIDE.md index 3ff79d24..e374e34f 100644 --- a/docs/FRONTEND_API_GUIDE.md +++ b/docs/FRONTEND_API_GUIDE.md @@ -12,7 +12,7 @@ | 환경 | Base URL | 비고 | |---|---|---| -| 로컬 백엔드 | `http://localhost:8080` | `application.yaml` 기본 포트. FE 정본(`Dvely_FE_test`)의 `VITE_API_BASE` 기본값과 동일 | +| 로컬 백엔드 | `http://localhost:8080` | `application.yaml` 기본 포트. FE 정본(`Dvely_FE`)의 `VITE_API_BASE` 기본값과 동일 | | Swagger UI | `http://localhost:8080/swagger-ui/index.html` | 도메인별 그룹 드롭다운(auth/project/chat/agent/deployment/domainbinding/cloudconnection/preview/environment/webhook/user)으로 필터링 가능 | | OpenAPI JSON | `http://localhost:8080/v3/api-docs` (그룹별: `/v3/api-docs/{group}`) | 코드 생성기(openapi-generator 등)에 바로 사용 가능 | @@ -118,7 +118,7 @@ SSE, GitHub webhook/App 콜백, preview 프록시(`@RawApiResponse` 마킹된 - 이 문서의 각 엔드포인트 표에 적힌 "응답"은 모두 이 envelope의 `data` 내용입니다. - **`@RawApiResponse`가 붙은 엔드포인트**(감싸지지 않고 바디를 그대로 반환): `GET /api/v1/auth/github/app/callback`(302 redirect, 바디 없음), `POST /api/v1/webhook/github`(202, 바디 없음), `GET /api/v1/agent/tasks/{taskId}/events/stream`(SSE), `GET /api/v1/previews/{sessionId}/{accessToken}/**`(프리뷰 앱의 원본 바이트 그대로 프록시). -### 3.2 FE 정본(`Dvely_FE_test`)의 unwrap 규약 +### 3.2 FE 정본(`Dvely_FE`)의 unwrap 규약 `src/api/http.ts`의 `request()`는 아래 규칙으로 동작합니다 — 신규 FE 프로젝트도 동일 패턴을 권장합니다. @@ -900,5 +900,5 @@ CODE 단계는 GitHub 저장소 연결과 무관하게 동작합니다(Docker ## 6. 참고 - 이 문서의 모든 필드명·상태값은 각 컨트롤러·DTO(`src/main/java/com/example/dvely/**/presentation/**`)와 일치합니다. 스키마가 바뀌면 이 문서도 함께 갱신해야 합니다. -- 통합 테스트/수동 시나리오는 FE 정본 저장소의 `Dvely_FE_test/API_TEST_FLOW.md`(콘솔 기반 수동 QA 흐름)도 함께 참고하세요. +- 통합 테스트/수동 시나리오는 **구** FE 저장소 `Dvely_FE_test` 의 `API_TEST_FLOW.md`(2026-07-19 이후 갱신 없음 — 지금의 정본은 `Dvely_FE` 다. 그 문서는 거기에만 있어 경로는 유지한다)(콘솔 기반 수동 QA 흐름)도 함께 참고하세요. - Swagger UI 상단 Info 블록(SwaggerConfig)에도 이 문서의 §3(공통 규약) 요약이 markdown으로 포함되어 있습니다. diff --git a/docs/qeploy-mcp-cli-design.md b/docs/qeploy-mcp-cli-design.md index 36770458..53da1aad 100644 --- a/docs/qeploy-mcp-cli-design.md +++ b/docs/qeploy-mcp-cli-design.md @@ -1,7 +1,7 @@ # Qeploy MCP 서버 · CLI 설계 (에이전트 연동) > 기준일: 2026-09-07 (2026-09-08 갱신) · 코드 실측 기반 -> 구현 상태: 1~5단계 완료 · PR #306 · FE 6단계는 `Dvely_FE` #76 +> 구현 상태: 1~5단계 완료 · PR #306 · FE 6단계는 `Dvely_FE` #76(완료) · **npm 미배포 — 실사용 불가**(#451) > 상위 PRD: `.notion/prd.md` 부록 A-2 · 요구사항: `.notion/srs.md` §B --- diff --git a/src/main/java/com/example/dvely/apitoken/presentation/dto/request/IssueApiTokenRequest.java b/src/main/java/com/example/dvely/apitoken/presentation/dto/request/IssueApiTokenRequest.java index a06b6d33..989b7038 100644 --- a/src/main/java/com/example/dvely/apitoken/presentation/dto/request/IssueApiTokenRequest.java +++ b/src/main/java/com/example/dvely/apitoken/presentation/dto/request/IssueApiTokenRequest.java @@ -16,7 +16,9 @@ public record IssueApiTokenRequest( @Schema(description = "구분용 이름(선택)", example = "내 노트북 Claude Code", nullable = true) @Size(max = 64) String label, - @Schema(description = "만료까지 일수. 생략 시 90일, 최대 365일", example = "90", nullable = true) + @Schema(description = "만료까지 일수. 스코프마다 다르다 — READ: 생략 시 90일·최대 365일, " + + "WRITE: 생략 시 30일·최대 90일(유출 시 할 수 있는 일이 달라서). 범위를 벗어나면 400", + example = "90", nullable = true) @Min(1) @Max(365) Integer expiresInDays ) { } From f3a4428c0d2c4ec60713caa2b0e835fd954999cf Mon Sep 17 00:00:00 2001 From: dldnsgkr <109467940+dldnsgkr@users.noreply.github.com> Date: Thu, 15 Oct 2026 13:06:47 +0900 Subject: [PATCH 3/3] =?UTF-8?q?fix(auth):=20PAT=20=EC=93=B0=EA=B8=B0?= =?UTF-8?q?=EB=A5=BC=20=EC=97=90=EC=9D=B4=EC=A0=84=ED=8A=B8=EC=9A=A9=20?= =?UTF-8?q?=EB=8B=A4=EC=84=AF=20=EA=B0=9C=EB=A1=9C=20=EC=A2=81=ED=9E=8C?= =?UTF-8?q?=EB=8B=A4=20[=20#453=20]=20(#454)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit WRITE PAT 가 브라우저 로그인과 같은 쓰기 권한이어서, 승인 결정·PAT 재발급·AI 키/AWS 연결 교체·삭제가 서버에서 막히지 않았다. "에이전트에 노출하지 않는다" 는 MCP 가 도구를 안 만든 것으로만 지켜졌다. 허용 목록(배포 생성·재시도, 환경변수 추가·수정, 도메인 추가)만 통과시키고 나머지 쓰기는 403. agent-tools 클라이언트의 쓰기 호출이 목록 밖으로 나가면 테스트가 깨진다. Co-authored-by: Claude Opus 5.5 --- docs/FRONTEND_API_GUIDE.md | 2 + docs/qeploy-mcp-cli-design.md | 3 +- .../security/JwtAuthenticationFilter.java | 44 ++++++- .../security/JwtAuthenticationFilterTest.java | 115 +++++++++++++++++- 4 files changed, 155 insertions(+), 9 deletions(-) diff --git a/docs/FRONTEND_API_GUIDE.md b/docs/FRONTEND_API_GUIDE.md index e374e34f..19792f75 100644 --- a/docs/FRONTEND_API_GUIDE.md +++ b/docs/FRONTEND_API_GUIDE.md @@ -594,6 +594,8 @@ POST /api/v1/api-tokens `WRITE` 가 짧은 이유는 유출됐을 때 할 수 있는 일이 다르기 때문이다 — 배포하고 환경변수를 바꾸고 도메인을 붙인다. 범위를 벗어나면 **400** 이고 메시지에 스코프가 들어 있으니 그대로 보여주면 된다. 발급 UI 의 만료일 입력은 선택한 스코프에 따라 상한을 바꿔야 한다. +**`WRITE` 도 모든 쓰기가 아니다(#453).** PAT 로 통과하는 쓰기는 배포 생성·재시도, 환경변수 추가·수정, 도메인 추가 다섯 개뿐이고, 승인·토큰 발급/폐기·AI 키·클라우드 연결·삭제 등 나머지는 403 이다. 브라우저 로그인(JWT)은 영향이 없다. 발급 UI 에서 `WRITE` 를 설명할 때 "모든 작업" 처럼 쓰지 말 것. + ### 4.12 AiCredential — `com.example.dvely.aiaccount.presentation` (3) 사용자 본인 AI API 키(BYOK) 관리. 등록한 키로 `CLAUDE_CODE` · `CODEX` 코딩 에이전트가 실행되고, 사용량은 사용자 계정으로 직접 청구된다. diff --git a/docs/qeploy-mcp-cli-design.md b/docs/qeploy-mcp-cli-design.md index 53da1aad..42eea591 100644 --- a/docs/qeploy-mcp-cli-design.md +++ b/docs/qeploy-mcp-cli-design.md @@ -95,6 +95,7 @@ npx @qeploy/cli deploy --project 12 1. **기본 비활성.** `QEPLOY_ENABLE_WRITES=true` 없이는 쓰기 도구가 목록에 아예 없다. 과하게 적극적인 에이전트의 실패는 틀린 답이 아니라 진짜 배포라, 기본값은 시작할 수 없는 쪽이어야 한다. 2. **서버 강제.** `READ` 스코프 PAT 는 변경 메서드에서 403 이다. 클라이언트를 우회해도, 플래그를 켜도 막힌다. 실측으로 확인했다. + `WRITE` PAT 도 **허용 목록의 쓰기 다섯 개**(배포 생성·재시도, 환경변수 추가·수정, 도메인 추가)만 통과하고 나머지 쓰기는 403 이다(#453). 그 전에는 WRITE PAT 가 브라우저와 같은 권한이라, "승인 결정·삭제는 노출하지 않는다"가 MCP 가 도구를 안 만든 것으로만 지켜졌다 — 셸이 있는 에이전트는 저장된 토큰으로 승인을 스스로 누르거나 PAT 로 PAT 를 재발급할 수 있었다. 목록은 `JwtAuthenticationFilter.PAT_WRITABLE` 이고, `agent-tools/packages/client` 의 쓰기 호출이 목록 밖으로 나가면 `JwtAuthenticationFilterTest` 가 깨진다. 에이전트에 쓰기를 새로 열 때는 그 목록에 한 줄을 더한다. 3. **승인 게이트.** 배포·인프라 변경의 기존 승인 흐름은 이 경로에서도 그대로다. 그래서 `qeploy_deploy` 응답이 200 이어도 "배포됨"이 아니라 "승인 대기"일 수 있고, 도구 설명이 그 점을 명시해 에이전트가 단정하지 않게 한다. 여기에 MCP 표준 `destructiveHint` 를 붙여 클라이언트가 실행 전에 사용자에게 물을 수 있게 했다. 우리 산문을 에이전트가 읽어주길 바라는 대신 프로토콜 자체의 수단을 쓴다. @@ -159,7 +160,7 @@ npx @qeploy/cli deploy --project 12 - **CLI 와 MCP 가 같은 파일을 읽는다.** 에이전트는 `npx @qeploy/mcp` 로 실행되어 환경변수를 받을 자리가 없고, 에이전트 설정 파일에 자격증명을 적게 하면 비밀이 있을 필요 없는 곳에 하나 더 생긴다. - **환경변수가 파일을 이긴다.** CI 가 `QEPLOY_TOKEN` 을 설정했는데 개발자의 로그인 파일이 조용히 덮어쓰면 안 된다. - **붙여넣은 것은 화면에 표시하지 않고, 발급한 PAT 도 출력하지 않는다.** 이미 저장했으므로 스크롤백에 90일짜리 자격증명을 남길 이유가 없다. -- **`logout` 은 로컬 파일만 지우고 그 사실을 말한다.** 서버 폐기에는 WRITE 스코프가 필요한데 기본이 READ 다. 죽은 줄 아는 자격증명이 살아 있는 것은 로그아웃이 없는 것보다 나쁘다. +- **`logout` 은 로컬 파일만 지우고 그 사실을 말한다.** 토큰 폐기는 PAT 로는 할 수 없고(#453) 웹에서만 된다. 죽은 줄 아는 자격증명이 살아 있는 것은 로그아웃이 없는 것보다 나쁘다. - **`qp_` 로 시작하는 값을 붙여넣으면 거절한다.** 이미 발급된 PAT 를 여기 넣으면 만료될 때까지는 "되는 것처럼" 보이다가 이유를 알 수 없게 실패한다. ### 왜 브라우저 왕복이 아닌가 diff --git a/src/main/java/com/example/dvely/auth/infrastructure/config/security/JwtAuthenticationFilter.java b/src/main/java/com/example/dvely/auth/infrastructure/config/security/JwtAuthenticationFilter.java index b63b981c..65f541c7 100644 --- a/src/main/java/com/example/dvely/auth/infrastructure/config/security/JwtAuthenticationFilter.java +++ b/src/main/java/com/example/dvely/auth/infrastructure/config/security/JwtAuthenticationFilter.java @@ -20,6 +20,7 @@ import java.util.List; import java.util.Optional; import java.util.Set; +import java.util.regex.Pattern; /** * Authenticates a request from either a browser JWT or an agent/CLI personal access token. @@ -41,6 +42,25 @@ public class JwtAuthenticationFilter extends OncePerRequestFilter { */ private static final Set WRITE_METHODS = Set.of("POST", "PUT", "PATCH", "DELETE"); + /** + * The only writes a PAT may make, as {@code METHOD path} — everything else needs a browser login. + * + *

An allowlist rather than a denylist: before #453 a WRITE token could approve its own + * approval requests, mint fresh tokens and swap the user's AWS connection, because the agent + * tools simply did not expose those and nothing on the server said no. With a denylist the next + * such endpoint is open until someone remembers it; here it is closed until someone opens it. + * + *

Kept in step with what {@code agent-tools/packages/client} actually calls. Giving agents a + * new write means adding a line here, on purpose. + */ + private static final List PAT_WRITABLE = List.of( + Pattern.compile("POST /api/v1/projects/\\d+/deployments"), + Pattern.compile("POST /api/v1/deployments/\\d+/retry"), + Pattern.compile("POST /api/v1/projects/\\d+/environment-variables"), + Pattern.compile("PATCH /api/v1/projects/\\d+/environment-variables/\\d+"), + Pattern.compile("POST /api/v1/projects/\\d+/domains") + ); + private final TokenPort tokenPort; private final TokenBlacklistPort tokenBlacklistPort; private final ApiTokenAuthenticator apiTokenAuthenticator; @@ -81,10 +101,12 @@ private boolean authenticateWithApiToken(String token, if (WRITE_METHODS.contains(request.getMethod()) && !apiToken.allowsWrite()) { // 403 rather than falling through to anonymous: the caller authenticated correctly and // simply lacks the scope, and a 401 would send them off to re-authenticate for nothing. - response.setStatus(HttpServletResponse.SC_FORBIDDEN); - response.setContentType("application/json;charset=UTF-8"); - response.getWriter().write( - "{\"code\":\"FORBIDDEN\",\"message\":\"이 토큰은 읽기 전용입니다. write 스코프 토큰이 필요합니다.\"}"); + reject(response, "이 토큰은 읽기 전용입니다. write 스코프 토큰이 필요합니다."); + return false; + } + + if (WRITE_METHODS.contains(request.getMethod()) && !patMayWrite(request)) { + reject(response, "이 작업은 개인 액세스 토큰으로 할 수 없습니다. 웹에서 진행해주세요."); return false; } @@ -97,6 +119,20 @@ private boolean authenticateWithApiToken(String token, return true; } + private static boolean patMayWrite(HttpServletRequest request) { + // getRequestURI is the raw path; the security firewall has already refused encoded slashes, + // "..", and ";" by the time this runs, and anything odd that slips past simply fails to + // match — which denies, the safe direction. + String call = request.getMethod() + " " + request.getRequestURI(); + return PAT_WRITABLE.stream().anyMatch(p -> p.matcher(call).matches()); + } + + private static void reject(HttpServletResponse response, String message) throws IOException { + response.setStatus(HttpServletResponse.SC_FORBIDDEN); + response.setContentType("application/json;charset=UTF-8"); + response.getWriter().write("{\"code\":\"FORBIDDEN\",\"message\":\"" + message + "\"}"); + } + private void authenticateWithJwt(String token) { try { // userId 와 jti 를 한 번의 파싱으로 함께 얻는다. 따로 받으면 같은 토큰에 대해 HMAC 키 diff --git a/src/test/java/com/example/dvely/auth/infrastructure/config/security/JwtAuthenticationFilterTest.java b/src/test/java/com/example/dvely/auth/infrastructure/config/security/JwtAuthenticationFilterTest.java index 2a455457..a457a26a 100644 --- a/src/test/java/com/example/dvely/auth/infrastructure/config/security/JwtAuthenticationFilterTest.java +++ b/src/test/java/com/example/dvely/auth/infrastructure/config/security/JwtAuthenticationFilterTest.java @@ -16,8 +16,12 @@ import com.example.dvely.auth.application.port.out.TokenClaims; import com.example.dvely.auth.application.port.out.TokenPort; import jakarta.servlet.FilterChain; +import java.nio.file.Files; +import java.nio.file.Path; import java.time.LocalDateTime; import java.util.Optional; +import java.util.regex.Matcher; +import java.util.regex.Pattern; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; @@ -60,7 +64,11 @@ private static ApiToken token(ApiTokenScope scope) { } private MockHttpServletResponse run(String method, String bearer) throws Exception { - MockHttpServletRequest request = new MockHttpServletRequest(method, "/api/v1/projects"); + return run(method, "/api/v1/projects", bearer); + } + + private MockHttpServletResponse run(String method, String path, String bearer) throws Exception { + MockHttpServletRequest request = new MockHttpServletRequest(method, path); if (bearer != null) { request.addHeader("Authorization", "Bearer " + bearer); } @@ -158,13 +166,112 @@ void aScopeRejectionStopsTheChainInsteadOfFallingThroughAsAnonymous() throws Exc } @Test - void aWriteTokenMayWrite() throws Exception { + void aWriteTokenMayMakeTheWritesAgentsAreGiven() throws Exception { + when(apiTokenAuthenticator.authenticate(PAT)).thenReturn(Optional.of(token(ApiTokenScope.WRITE))); + + for (String call : new String[] { + "POST /api/v1/projects/3/deployments", + "POST /api/v1/deployments/12/retry", + "POST /api/v1/projects/3/environment-variables", + "PATCH /api/v1/projects/3/environment-variables/40", + "POST /api/v1/projects/3/domains"}) { + SecurityContextHolder.clearContext(); + String[] parts = call.split(" "); + + MockHttpServletResponse response = run(parts[0], parts[1], PAT); + + assertThat(response.getStatus()).withFailMessage("%s 가 통과해야 합니다", call).isEqualTo(200); + assertThat(authenticatedUserId()).isEqualTo(7L); + } + } + + @Test + void aWriteTokenIsRefusedOnWritesThatBelongToAPerson() throws Exception { + // #453: before the allowlist, these all went through with a WRITE token. The agent tools + // just never called them, and nothing on the server said no — so an agent with a shell + // could approve its own approval request, or mint itself a token that outlives this one. when(apiTokenAuthenticator.authenticate(PAT)).thenReturn(Optional.of(token(ApiTokenScope.WRITE))); - MockHttpServletResponse response = run("POST", PAT); + for (String call : new String[] { + "POST /api/v1/approvals/5/approve", + "POST /api/v1/approvals/5/reject", + "POST /api/v1/api-tokens", + "DELETE /api/v1/api-tokens/9", + "PUT /api/v1/ai-credentials/OPENAI", + "POST /api/v1/cloud-connections", + "DELETE /api/v1/cloud-connections/2", + "DELETE /api/v1/projects/3", + "POST /api/v1/servers/8/terminate", + "PUT /api/v1/projects/3/settings/cost-budget"}) { + SecurityContextHolder.clearContext(); + String[] parts = call.split(" "); + + MockHttpServletResponse response = run(parts[0], parts[1], PAT); + + assertThat(response.getStatus()).withFailMessage("%s 가 403 이어야 합니다", call).isEqualTo(403); + assertThat(response.getContentAsString()).contains("FORBIDDEN"); + assertThat(authenticatedUserId()).isNull(); + } + verify(chain, never()).doFilter(any(), any()); + } + + @Test + void theAllowlistMatchesWholePathsOnly() throws Exception { + // A prefix or suffix match would let a neighbouring endpoint ride on an allowed one. + when(apiTokenAuthenticator.authenticate(PAT)).thenReturn(Optional.of(token(ApiTokenScope.WRITE))); + + for (String path : new String[] { + "/api/v1/projects/3/deployments/extra", + "/api/v1/projects/3/domainsX", + "/evil/api/v1/projects/3/deployments", + "/api/v1/projects/abc/deployments"}) { + SecurityContextHolder.clearContext(); + + assertThat(run("POST", path, PAT).getStatus()) + .withFailMessage("%s 가 403 이어야 합니다", path).isEqualTo(403); + } + } + + @Test + void aBrowserLoginIsNotNarrowedByThePatAllowlist() throws Exception { + when(tokenPort.parseClaims(JWT)).thenReturn(claims()); + when(blacklist.isRevoked("jti-1")).thenReturn(false); + + MockHttpServletResponse response = run("POST", "/api/v1/approvals/5/approve", JWT); assertThat(response.getStatus()).isEqualTo(200); - assertThat(authenticatedUserId()).isEqualTo(7L); + assertThat(authenticatedUserId()).isEqualTo(42L); + } + + @Test + void everyWriteTheAgentClientMakesIsAllowed() throws Exception { + // The allowlist is hand-kept beside agent-tools. If the client grows a write and nobody adds + // it here, MCP breaks with a 403 that only shows up in a user's terminal — so read the + // client's own source and check each write it makes. + // + // Token issuance is excluded on purpose: the CLI calls it with a browser JWT, never a PAT. + String client = Files.readString(Path.of("agent-tools/packages/client/src/index.js")); + Matcher m = Pattern.compile( + "this\\.(?:(post|patch|put|delete)\\(|request\\('(POST|PATCH|PUT|DELETE)',\\s*)[`']([^`']+)[`']") + .matcher(client); + when(apiTokenAuthenticator.authenticate(PAT)).thenReturn(Optional.of(token(ApiTokenScope.WRITE))); + + int checked = 0; + while (m.find()) { + String method = (m.group(1) != null ? m.group(1) : m.group(2)).toUpperCase(); + String path = m.group(3).replaceAll("\\$\\{[^}]+}", "1"); + if (path.startsWith("/api/v1/api-tokens")) { + continue; + } + SecurityContextHolder.clearContext(); + + assertThat(run(method, path, PAT).getStatus()) + .withFailMessage("agent-tools 가 %s %s 를 쓰는데 허용 목록에 없습니다", method, path) + .isEqualTo(200); + checked++; + } + // An empty loop would pass while proving nothing — e.g. if the client's call style changed. + assertThat(checked).isEqualTo(5); } @Test