From 034b94a59d78c949163912545ea545509fdbc61d Mon Sep 17 00:00:00 2001 From: seonghobae <8172694+seonghobae@users.noreply.github.com> Date: Tue, 28 Jul 2026 22:16:38 +0000 Subject: [PATCH] =?UTF-8?q?feat:=20=EA=B4=80=EB=A6=AC=EC=9E=90=20API=20?= =?UTF-8?q?=EB=B3=B4=EC=95=88=20=EC=9D=B8=EC=A6=9D=20=EA=B0=95=ED=99=94=20?= =?UTF-8?q?(Admin=20API=20Security=20Enforcement)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 관리자 전용 엔드포인트(`GET /api/v1/admin/convert/jobs`, `DELETE /api/v1/admin/convert/jobs/{jobId}`, `POST /api/v1/admin/convert/jobs/{jobId}/retry`)에 `TenantAccessService`를 통한 보안 검증 도입 - `TenantPermissions.java`에 `ADMIN_READ`, `ADMIN_WRITE` 권한 추가 - `AdminControllerTest.java`에 권한 검증 누락 시 403 Forbidden 응답 및 성공 케이스 단위 테스트 추가 (100% 커버리지) - CHANGELOG.md 내용 업데이트 --- CHANGELOG.md | 4 + .../viewer/auth/TenantPermissions.java | 10 +++ .../viewer/controller/AdminController.java | 22 ++++- .../controller/AdminControllerTest.java | 89 ++++++++++++++++++- 4 files changed, 120 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e8e8c9806..66988c73a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -37,6 +37,10 @@ - 신규 구현된 Repository, Service, Controller 계층에 대한 유닛 테스트(Unit Tests)를 작성하여 JaCoCo 기준 라인 및 브랜치 커버리지 100%를 달성했습니다. ### 보안 (Security) +- **관리자 API 보안 인증 강화 (Admin API Security Enforcement)** + - 관리자 전용 엔드포인트(`GET /api/v1/admin/convert/jobs`, `DELETE /api/v1/admin/convert/jobs/{jobId}`, `POST /api/v1/admin/convert/jobs/{jobId}/retry`)에 `TenantAccessService`를 통한 보안 검증을 도입했습니다. + - 각각 `ADMIN_READ`, `ADMIN_WRITE` 권한(TenantPermissions)을 요구하도록 적용하여 허가되지 않은 사용자의 접근을 차단했습니다. + - **의존성 취약점 일괄 정리 (trivy-fs / osv-scan 대응)**: Spring Boot 부모 POM을 `3.5.0`에서 `3.5.16`으로 올려 Spring Framework, Netty, Reactor Netty, logback 관련 다수의 HIGH/MEDIUM 권고를 해소했습니다. - Jackson 계열을 `jackson-bom` import로 `2.22.1`에 고정하여 jackson-databind case-insensitive deserialization bypass 권고(GHSA-5jmj-h7xm-6q6v / CVE-2026-54515)를 제거했습니다. - Apache Tika 표준 파서를 통해 유입되던 전이 의존성을 `dependencyManagement`로 고정했습니다: junrar `7.6.0`(경로 순회 RCE/파일 쓰기), commons-io `2.20.0`(XmlStreamReader DoS), commons-lang3 `3.18.0`, BouncyCastle `bcprov-jdk18on 1.84` 및 `bcpkix-jdk18on 1.84`(CRITICAL/Medium). 전체 347개 테스트 통과를 확인했습니다. diff --git a/src/main/java/com/clearfolio/viewer/auth/TenantPermissions.java b/src/main/java/com/clearfolio/viewer/auth/TenantPermissions.java index ced5e6a3d..d268c41b8 100644 --- a/src/main/java/com/clearfolio/viewer/auth/TenantPermissions.java +++ b/src/main/java/com/clearfolio/viewer/auth/TenantPermissions.java @@ -50,6 +50,16 @@ public final class TenantPermissions { */ public static final String ANALYTICS_READ = "analytics:read"; + /** + * Permission required to read admin data. + */ + public static final String ADMIN_READ = "admin:read"; + + /** + * Permission required to write admin data. + */ + public static final String ADMIN_WRITE = "admin:write"; + private TenantPermissions() { } } diff --git a/src/main/java/com/clearfolio/viewer/controller/AdminController.java b/src/main/java/com/clearfolio/viewer/controller/AdminController.java index 412d4eb86..f9ede4e40 100644 --- a/src/main/java/com/clearfolio/viewer/controller/AdminController.java +++ b/src/main/java/com/clearfolio/viewer/controller/AdminController.java @@ -14,7 +14,12 @@ import org.springframework.web.bind.annotation.RestController; import org.springframework.web.server.ResponseStatusException; +import org.springframework.http.HttpHeaders; +import org.springframework.web.bind.annotation.RequestHeader; + import com.clearfolio.viewer.api.AdminJobListResponse; +import com.clearfolio.viewer.auth.TenantAccessService; +import com.clearfolio.viewer.auth.TenantPermissions; import com.clearfolio.viewer.model.ConversionJob; import com.clearfolio.viewer.service.DocumentConversionService; import com.clearfolio.viewer.service.RetryDeadLetterResult; @@ -26,24 +31,29 @@ public class AdminController { private final DocumentConversionService conversionService; + private final TenantAccessService tenantAccessService; /** * Creates a controller for admin operations. * * @param conversionService conversion service + * @param tenantAccessService tenant access service */ - public AdminController(DocumentConversionService conversionService) { + public AdminController(DocumentConversionService conversionService, TenantAccessService tenantAccessService) { this.conversionService = conversionService; + this.tenantAccessService = tenantAccessService; } /** * Retrieves all conversion jobs, optionally filtered by dead-letter status. * * @param deadLettered optional filter for dead-lettered jobs + * @param headers request headers * @return list of conversion jobs */ @GetMapping("/api/v1/admin/convert/jobs") - public AdminJobListResponse getAllJobs(@RequestParam(required = false) Boolean deadLettered) { + public AdminJobListResponse getAllJobs(@RequestParam(required = false) Boolean deadLettered, @RequestHeader HttpHeaders headers) { + tenantAccessService.require(headers, TenantPermissions.ADMIN_READ); Iterable allJobs = conversionService.getAllJobs(); if (deadLettered == null) { @@ -63,10 +73,12 @@ public AdminJobListResponse getAllJobs(@RequestParam(required = false) Boolean d * Deletes a conversion job. * * @param jobId conversion job identifier + * @param headers request headers * @return no content on success */ @DeleteMapping("/api/v1/admin/convert/jobs/{jobId}") - public ResponseEntity deleteJob(@PathVariable UUID jobId) { + public ResponseEntity deleteJob(@PathVariable UUID jobId, @RequestHeader HttpHeaders headers) { + tenantAccessService.require(headers, TenantPermissions.ADMIN_WRITE); conversionService.deleteJob(jobId); return ResponseEntity.noContent().build(); } @@ -75,10 +87,12 @@ public ResponseEntity deleteJob(@PathVariable UUID jobId) { * Retries a dead-lettered conversion job. * * @param jobId conversion job identifier + * @param headers request headers * @return accepted response on success */ @PostMapping("/api/v1/admin/convert/jobs/{jobId}/retry") - public ResponseEntity retryDeadLettered(@PathVariable UUID jobId) { + public ResponseEntity retryDeadLettered(@PathVariable UUID jobId, @RequestHeader HttpHeaders headers) { + tenantAccessService.require(headers, TenantPermissions.ADMIN_WRITE); RetryDeadLetterResult result = conversionService.retryDeadLettered(jobId, "admin"); if (result == RetryDeadLetterResult.NOT_FOUND) { throw new ResponseStatusException(HttpStatus.NOT_FOUND, "job not found"); diff --git a/src/test/java/com/clearfolio/viewer/controller/AdminControllerTest.java b/src/test/java/com/clearfolio/viewer/controller/AdminControllerTest.java index ad63a8015..71f435a96 100644 --- a/src/test/java/com/clearfolio/viewer/controller/AdminControllerTest.java +++ b/src/test/java/com/clearfolio/viewer/controller/AdminControllerTest.java @@ -10,6 +10,8 @@ import org.junit.jupiter.api.Test; import org.springframework.test.web.reactive.server.WebTestClient; +import com.clearfolio.viewer.auth.TenantAccessService; +import org.springframework.http.HttpHeaders; import com.clearfolio.viewer.model.ConversionJob; import com.clearfolio.viewer.service.DocumentConversionService; import com.clearfolio.viewer.service.RetryDeadLetterResult; @@ -17,13 +19,15 @@ class AdminControllerTest { private DocumentConversionService conversionService; + private TenantAccessService tenantAccessService; private WebTestClient webTestClient; private AdminController controller; @BeforeEach void setUp() { conversionService = mock(DocumentConversionService.class); - controller = new AdminController(conversionService); + tenantAccessService = mock(TenantAccessService.class); + controller = new AdminController(conversionService, tenantAccessService); webTestClient = WebTestClient.bindToController(controller) .controllerAdvice(new ApiExceptionHandler()) .build(); @@ -37,12 +41,18 @@ void getAllJobsReturnsAllJobsWhenNoFilterProvided() { webTestClient.get() .uri("/api/v1/admin/convert/jobs") + .header("X-Dummy", "dummy") .exchange() .expectStatus().isOk() .expectBody() .jsonPath("$.jobs.length()").isEqualTo(2) .jsonPath("$.jobs[0].fileName").isEqualTo("a.pdf") .jsonPath("$.jobs[1].fileName").isEqualTo("b.pdf"); + + org.mockito.Mockito.verify(tenantAccessService).require( + org.mockito.ArgumentMatchers.any(HttpHeaders.class), + org.mockito.ArgumentMatchers.eq(com.clearfolio.viewer.auth.TenantPermissions.ADMIN_READ) + ); } @Test @@ -55,11 +65,30 @@ void getAllJobsFiltersByDeadLetteredTrue() { webTestClient.get() .uri("/api/v1/admin/convert/jobs?deadLettered=true") + .header("X-Dummy", "dummy") .exchange() .expectStatus().isOk() .expectBody() .jsonPath("$.jobs.length()").isEqualTo(1) .jsonPath("$.jobs[0].fileName").isEqualTo("a.pdf"); + + org.mockito.Mockito.verify(tenantAccessService).require( + org.mockito.ArgumentMatchers.any(HttpHeaders.class), + org.mockito.ArgumentMatchers.eq(com.clearfolio.viewer.auth.TenantPermissions.ADMIN_READ) + ); + } + + @Test + void getAllJobsRequiresAdminReadPermission() { + when(tenantAccessService.require( + org.mockito.ArgumentMatchers.any(HttpHeaders.class), + org.mockito.ArgumentMatchers.eq(com.clearfolio.viewer.auth.TenantPermissions.ADMIN_READ) + )).thenThrow(new org.springframework.web.server.ResponseStatusException(org.springframework.http.HttpStatus.FORBIDDEN, "missing permission")); + + webTestClient.get() + .uri("/api/v1/admin/convert/jobs") + .exchange() + .expectStatus().isForbidden(); } @Test @@ -72,11 +101,31 @@ void getAllJobsFiltersByDeadLetteredFalse() { webTestClient.get() .uri("/api/v1/admin/convert/jobs?deadLettered=false") + .header("X-Dummy", "dummy") .exchange() .expectStatus().isOk() .expectBody() .jsonPath("$.jobs.length()").isEqualTo(1) .jsonPath("$.jobs[0].fileName").isEqualTo("b.pdf"); + + org.mockito.Mockito.verify(tenantAccessService).require( + org.mockito.ArgumentMatchers.any(HttpHeaders.class), + org.mockito.ArgumentMatchers.eq(com.clearfolio.viewer.auth.TenantPermissions.ADMIN_READ) + ); + } + + @Test + void deleteJobRequiresAdminWritePermission() { + UUID jobId = UUID.randomUUID(); + when(tenantAccessService.require( + org.mockito.ArgumentMatchers.any(HttpHeaders.class), + org.mockito.ArgumentMatchers.eq(com.clearfolio.viewer.auth.TenantPermissions.ADMIN_WRITE) + )).thenThrow(new org.springframework.web.server.ResponseStatusException(org.springframework.http.HttpStatus.FORBIDDEN, "missing permission")); + + webTestClient.delete() + .uri("/api/v1/admin/convert/jobs/" + jobId) + .exchange() + .expectStatus().isForbidden(); } @Test @@ -85,8 +134,28 @@ void deleteJobReturnsNoContent() { webTestClient.delete() .uri("/api/v1/admin/convert/jobs/" + jobId) + .header("X-Dummy", "dummy") .exchange() .expectStatus().isNoContent(); + + org.mockito.Mockito.verify(tenantAccessService).require( + org.mockito.ArgumentMatchers.any(HttpHeaders.class), + org.mockito.ArgumentMatchers.eq(com.clearfolio.viewer.auth.TenantPermissions.ADMIN_WRITE) + ); + } + + @Test + void retryDeadLetteredRequiresAdminWritePermission() { + UUID jobId = UUID.randomUUID(); + when(tenantAccessService.require( + org.mockito.ArgumentMatchers.any(HttpHeaders.class), + org.mockito.ArgumentMatchers.eq(com.clearfolio.viewer.auth.TenantPermissions.ADMIN_WRITE) + )).thenThrow(new org.springframework.web.server.ResponseStatusException(org.springframework.http.HttpStatus.FORBIDDEN, "missing permission")); + + webTestClient.post() + .uri("/api/v1/admin/convert/jobs/" + jobId + "/retry") + .exchange() + .expectStatus().isForbidden(); } @Test @@ -96,8 +165,14 @@ void retryDeadLetteredReturnsAcceptedWhenAccepted() { webTestClient.post() .uri("/api/v1/admin/convert/jobs/" + jobId + "/retry") + .header("X-Dummy", "dummy") .exchange() .expectStatus().isAccepted(); + + org.mockito.Mockito.verify(tenantAccessService).require( + org.mockito.ArgumentMatchers.any(HttpHeaders.class), + org.mockito.ArgumentMatchers.eq(com.clearfolio.viewer.auth.TenantPermissions.ADMIN_WRITE) + ); } @Test @@ -107,8 +182,14 @@ void retryDeadLetteredReturnsNotFoundWhenNotFound() { webTestClient.post() .uri("/api/v1/admin/convert/jobs/" + jobId + "/retry") + .header("X-Dummy", "dummy") .exchange() .expectStatus().isNotFound(); + + org.mockito.Mockito.verify(tenantAccessService).require( + org.mockito.ArgumentMatchers.any(HttpHeaders.class), + org.mockito.ArgumentMatchers.eq(com.clearfolio.viewer.auth.TenantPermissions.ADMIN_WRITE) + ); } @Test @@ -118,7 +199,13 @@ void retryDeadLetteredReturnsConflictWhenNotEligible() { webTestClient.post() .uri("/api/v1/admin/convert/jobs/" + jobId + "/retry") + .header("X-Dummy", "dummy") .exchange() .expectStatus().isEqualTo(409); // isConflict() isn't always available depending on spring-test version, so using isEqualTo(409) is safer + + org.mockito.Mockito.verify(tenantAccessService).require( + org.mockito.ArgumentMatchers.any(HttpHeaders.class), + org.mockito.ArgumentMatchers.eq(com.clearfolio.viewer.auth.TenantPermissions.ADMIN_WRITE) + ); } }