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/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..19792f75 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 프로젝트도 동일 패턴을 권장합니다. @@ -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` 코딩 에이전트가 실행되고, 사용량은 사용자 계정으로 직접 청구된다. @@ -900,5 +902,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/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` — 비어 있으면 미릴리스 없음. diff --git a/docs/qeploy-mcp-cli-design.md b/docs/qeploy-mcp-cli-design.md index 36770458..42eea591 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 --- @@ -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/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 ) { } 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