From 748e9fc2a9d0bd13b6a5581eec3cf15ef6caa970 Mon Sep 17 00:00:00 2001 From: seonghobae <8172694+seonghobae@users.noreply.github.com> Date: Mon, 27 Jul 2026 22:11:24 +0000 Subject: [PATCH] =?UTF-8?q?feat(admin):=20=EA=B4=80=EB=A6=AC=EC=9E=90=20?= =?UTF-8?q?=EC=BB=A8=ED=8A=B8=EB=A1=A4=EB=9F=AC=EC=97=90=20=EA=B6=8C?= =?UTF-8?q?=ED=95=9C=20=EA=B2=80=EC=A6=9D=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `AdminController`의 엔드포인트에 인증 및 권한(`ADMIN_READ`, `ADMIN_WRITE`) 검증 로직을 추가하여 무단 접근을 방지하도록 개선하였습니다. - `TenantPermissions`에 `ADMIN_READ` 및 `ADMIN_WRITE` 권한 상수 추가 - `AdminController` 생성자에 `TenantAccessService` 주입 - 각 엔드포인트에 `TenantAccessService.require()` 호출 추가 - `AdminControllerTest`에 인증 서비스 Mock 및 검증 로직 추가 --- commit_message.txt | 9 ++++--- .../viewer/auth/TenantPermissions.java | 10 ++++++++ .../viewer/controller/AdminController.java | 19 +++++++++++--- .../controller/AdminControllerTest.java | 25 ++++++++++++++++++- 4 files changed, 55 insertions(+), 8 deletions(-) diff --git a/commit_message.txt b/commit_message.txt index 32f35276b..6b0482233 100644 --- a/commit_message.txt +++ b/commit_message.txt @@ -1,4 +1,7 @@ -🛡️ Sentinel: [CRITICAL] 파일 업로드 경로 조작(Path Traversal) 취약점 수정 +feat(admin): 관리자 컨트롤러에 권한 검증 추가 -MultipartFile.getOriginalFilename()을 신뢰하여 발생할 수 있는 경로 조작 취약점을 수정했습니다. -StringUtils.cleanPath()를 사용하여 경로를 정규화하고 순수한 파일명만 추출하여 악의적인 페이로드(예: ../../../etc/passwd.hwp)로부터 시스템을 보호합니다. +`AdminController`의 엔드포인트에 인증 및 권한(`ADMIN_READ`, `ADMIN_WRITE`) 검증 로직을 추가하여 무단 접근을 방지하도록 개선하였습니다. +- `TenantPermissions`에 `ADMIN_READ` 및 `ADMIN_WRITE` 권한 상수 추가 +- `AdminController` 생성자에 `TenantAccessService` 주입 +- 각 엔드포인트에 `TenantAccessService.require()` 호출 추가 +- `AdminControllerTest`에 인증 서비스 Mock 및 검증 로직 추가 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..3ab61acd8 100644 --- a/src/main/java/com/clearfolio/viewer/controller/AdminController.java +++ b/src/main/java/com/clearfolio/viewer/controller/AdminController.java @@ -13,6 +13,11 @@ import org.springframework.web.bind.annotation.RequestParam; 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.auth.TenantAccessService; +import com.clearfolio.viewer.auth.TenantPermissions; +import com.clearfolio.viewer.auth.TenantContext; import com.clearfolio.viewer.api.AdminJobListResponse; import com.clearfolio.viewer.model.ConversionJob; @@ -25,6 +30,8 @@ @RestController public class AdminController { + private final TenantAccessService tenantAccessService; + private final DocumentConversionService conversionService; /** @@ -32,7 +39,8 @@ public class AdminController { * * @param conversionService conversion service */ - public AdminController(DocumentConversionService conversionService) { + public AdminController(DocumentConversionService conversionService, TenantAccessService tenantAccessService) { + this.tenantAccessService = tenantAccessService; this.conversionService = conversionService; } @@ -43,7 +51,8 @@ public AdminController(DocumentConversionService conversionService) { * @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) { @@ -66,7 +75,8 @@ public AdminJobListResponse getAllJobs(@RequestParam(required = false) Boolean d * @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(); } @@ -78,7 +88,8 @@ public ResponseEntity deleteJob(@PathVariable UUID jobId) { * @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..67ba4ff0f 100644 --- a/src/test/java/com/clearfolio/viewer/controller/AdminControllerTest.java +++ b/src/test/java/com/clearfolio/viewer/controller/AdminControllerTest.java @@ -2,11 +2,18 @@ import static org.mockito.Mockito.mock; import static org.mockito.Mockito.when; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; import java.util.Arrays; import java.util.UUID; import org.junit.jupiter.api.BeforeEach; +import org.springframework.http.HttpHeaders; +import com.clearfolio.viewer.auth.TenantAccessService; +import com.clearfolio.viewer.auth.TenantPermissions; +import com.clearfolio.viewer.auth.TenantContext; +import java.util.Set; import org.junit.jupiter.api.Test; import org.springframework.test.web.reactive.server.WebTestClient; @@ -17,13 +24,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(); @@ -34,6 +43,8 @@ void getAllJobsReturnsAllJobsWhenNoFilterProvided() { ConversionJob job1 = new ConversionJob(UUID.randomUUID(), "a.pdf", "application/pdf", "hash-a", 100L); ConversionJob job2 = new ConversionJob(UUID.randomUUID(), "b.pdf", "application/pdf", "hash-b", 100L); when(conversionService.getAllJobs()).thenReturn(Arrays.asList(job1, job2)); + TenantContext ctx = new TenantContext("admin", "admin", Set.of(TenantPermissions.ADMIN_READ)); + when(tenantAccessService.require(any(), eq(TenantPermissions.ADMIN_READ))).thenReturn(ctx); webTestClient.get() .uri("/api/v1/admin/convert/jobs") @@ -52,6 +63,8 @@ void getAllJobsFiltersByDeadLetteredTrue() { ConversionJob job2 = new ConversionJob(UUID.randomUUID(), "b.pdf", "application/pdf", "hash-b", 100L); when(conversionService.getAllJobs()).thenReturn(Arrays.asList(job1, job2)); + TenantContext ctx = new TenantContext("admin", "admin", Set.of(TenantPermissions.ADMIN_READ)); + when(tenantAccessService.require(any(), eq(TenantPermissions.ADMIN_READ))).thenReturn(ctx); webTestClient.get() .uri("/api/v1/admin/convert/jobs?deadLettered=true") @@ -69,6 +82,8 @@ void getAllJobsFiltersByDeadLetteredFalse() { ConversionJob job2 = new ConversionJob(UUID.randomUUID(), "b.pdf", "application/pdf", "hash-b", 100L); when(conversionService.getAllJobs()).thenReturn(Arrays.asList(job1, job2)); + TenantContext ctx = new TenantContext("admin", "admin", Set.of(TenantPermissions.ADMIN_READ)); + when(tenantAccessService.require(any(), eq(TenantPermissions.ADMIN_READ))).thenReturn(ctx); webTestClient.get() .uri("/api/v1/admin/convert/jobs?deadLettered=false") @@ -82,6 +97,8 @@ void getAllJobsFiltersByDeadLetteredFalse() { @Test void deleteJobReturnsNoContent() { UUID jobId = UUID.randomUUID(); + TenantContext ctx = new TenantContext("admin", "admin", Set.of(TenantPermissions.ADMIN_WRITE)); + when(tenantAccessService.require(any(), eq(TenantPermissions.ADMIN_WRITE))).thenReturn(ctx); webTestClient.delete() .uri("/api/v1/admin/convert/jobs/" + jobId) @@ -92,6 +109,8 @@ void deleteJobReturnsNoContent() { @Test void retryDeadLetteredReturnsAcceptedWhenAccepted() { UUID jobId = UUID.randomUUID(); + TenantContext ctx = new TenantContext("admin", "admin", Set.of(TenantPermissions.ADMIN_WRITE)); + when(tenantAccessService.require(any(), eq(TenantPermissions.ADMIN_WRITE))).thenReturn(ctx); when(conversionService.retryDeadLettered(jobId, "admin")).thenReturn(RetryDeadLetterResult.ACCEPTED); webTestClient.post() @@ -103,6 +122,8 @@ void retryDeadLetteredReturnsAcceptedWhenAccepted() { @Test void retryDeadLetteredReturnsNotFoundWhenNotFound() { UUID jobId = UUID.randomUUID(); + TenantContext ctx = new TenantContext("admin", "admin", Set.of(TenantPermissions.ADMIN_WRITE)); + when(tenantAccessService.require(any(), eq(TenantPermissions.ADMIN_WRITE))).thenReturn(ctx); when(conversionService.retryDeadLettered(jobId, "admin")).thenReturn(RetryDeadLetterResult.NOT_FOUND); webTestClient.post() @@ -114,6 +135,8 @@ void retryDeadLetteredReturnsNotFoundWhenNotFound() { @Test void retryDeadLetteredReturnsConflictWhenNotEligible() { UUID jobId = UUID.randomUUID(); + TenantContext ctx = new TenantContext("admin", "admin", Set.of(TenantPermissions.ADMIN_WRITE)); + when(tenantAccessService.require(any(), eq(TenantPermissions.ADMIN_WRITE))).thenReturn(ctx); when(conversionService.retryDeadLettered(jobId, "admin")).thenReturn(RetryDeadLetterResult.NOT_ELIGIBLE); webTestClient.post()