From 41fe78e16c5254c2d9c4bf64bdda79b00fcc918a Mon Sep 17 00:00:00 2001 From: seonghobae <8172694+seonghobae@users.noreply.github.com> Date: Sat, 18 Jul 2026 22:09:27 +0000 Subject: [PATCH] =?UTF-8?q?feat:=20AdminController=EC=97=90=20TenantAccess?= =?UTF-8?q?Service=20=EA=B6=8C=ED=95=9C=20=EA=B2=80=EC=A6=9D=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `AdminController`의 엔드포인트에 `TenantAccessService`를 통한 `ADMIN_READ`, `ADMIN_WRITE` 권한 검증을 추가하여 보안을 강화했습니다. `TenantPermissions`에 신규 권한 문자열을 정의하고, 이를 통해 적절한 권한이 있는 사용자만 관리자용 API를 호출할 수 있도록 했습니다. --- CHANGELOG.md | 1 + .../viewer/auth/TenantPermissions.java | 10 +++++++ .../viewer/controller/AdminController.java | 27 ++++++++++++++----- .../controller/AdminControllerTest.java | 25 ++++++++++++++++- update_changelog.py | 14 ++++++++++ 5 files changed, 69 insertions(+), 8 deletions(-) create mode 100644 update_changelog.py diff --git a/CHANGELOG.md b/CHANGELOG.md index e8e8c980..332120af 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,7 @@ ## [Unreleased] ### 추가된 기능 (Added) +- **관리자 API 권한 검증 로직 추가**: `AdminController`의 엔드포인트에 `TenantAccessService`를 통한 `ADMIN_READ`, `ADMIN_WRITE` 권한 검증을 추가하여 보안을 강화했습니다. - **관리자용 단건 작업 삭제 및 재시도 API 추가** - 특정 변환 작업을 삭제할 수 있는 `DELETE /api/v1/admin/convert/jobs/{jobId}` 엔드포인트를 추가했습니다. - 실패(dead-lettered) 상태인 작업을 관리자가 재시도 큐에 등록할 수 있는 `POST /api/v1/admin/convert/jobs/{jobId}/retry` 엔드포인트를 추가했습니다. diff --git a/src/main/java/com/clearfolio/viewer/auth/TenantPermissions.java b/src/main/java/com/clearfolio/viewer/auth/TenantPermissions.java index ced5e6a3..32f8c99b 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 perform administrative actions. + */ + 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 412d4eb8..63600929 100644 --- a/src/main/java/com/clearfolio/viewer/controller/AdminController.java +++ b/src/main/java/com/clearfolio/viewer/controller/AdminController.java @@ -4,17 +4,21 @@ import java.util.List; import java.util.UUID; +import org.springframework.http.HttpHeaders; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.DeleteMapping; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestHeader; import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; import org.springframework.web.server.ResponseStatusException; 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 +30,29 @@ public class AdminController { private final DocumentConversionService conversionService; + private final TenantAccessService tenantAccessService; /** * Creates a controller for admin operations. * - * @param conversionService conversion service + * @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 headers request headers * @param deadLettered optional filter for dead-lettered jobs * @return list of conversion jobs */ @GetMapping("/api/v1/admin/convert/jobs") - public AdminJobListResponse getAllJobs(@RequestParam(required = false) Boolean deadLettered) { + public AdminJobListResponse getAllJobs(@RequestHeader HttpHeaders headers, @RequestParam(required = false) Boolean deadLettered) { + tenantAccessService.require(headers, TenantPermissions.ADMIN_READ); Iterable allJobs = conversionService.getAllJobs(); if (deadLettered == null) { @@ -62,11 +71,13 @@ public AdminJobListResponse getAllJobs(@RequestParam(required = false) Boolean d /** * Deletes a conversion job. * - * @param jobId conversion job identifier + * @param headers request headers + * @param jobId conversion job identifier * @return no content on success */ @DeleteMapping("/api/v1/admin/convert/jobs/{jobId}") - public ResponseEntity deleteJob(@PathVariable UUID jobId) { + public ResponseEntity deleteJob(@RequestHeader HttpHeaders headers, @PathVariable UUID jobId) { + tenantAccessService.require(headers, TenantPermissions.ADMIN_WRITE); conversionService.deleteJob(jobId); return ResponseEntity.noContent().build(); } @@ -74,11 +85,13 @@ public ResponseEntity deleteJob(@PathVariable UUID jobId) { /** * Retries a dead-lettered conversion job. * - * @param jobId conversion job identifier + * @param headers request headers + * @param jobId conversion job identifier * @return accepted response on success */ @PostMapping("/api/v1/admin/convert/jobs/{jobId}/retry") - public ResponseEntity retryDeadLettered(@PathVariable UUID jobId) { + public ResponseEntity retryDeadLettered(@RequestHeader HttpHeaders headers, @PathVariable UUID jobId) { + 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 ad63a801..a6ece828 100644 --- a/src/test/java/com/clearfolio/viewer/controller/AdminControllerTest.java +++ b/src/test/java/com/clearfolio/viewer/controller/AdminControllerTest.java @@ -1,5 +1,7 @@ package com.clearfolio.viewer.controller; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.when; @@ -10,6 +12,9 @@ import org.junit.jupiter.api.Test; import org.springframework.test.web.reactive.server.WebTestClient; +import com.clearfolio.viewer.auth.TenantAccessService; +import com.clearfolio.viewer.auth.TenantContext; +import com.clearfolio.viewer.auth.TenantPermissions; import com.clearfolio.viewer.model.ConversionJob; import com.clearfolio.viewer.service.DocumentConversionService; import com.clearfolio.viewer.service.RetryDeadLetterResult; @@ -17,16 +22,20 @@ class AdminControllerTest { private DocumentConversionService conversionService; + private TenantAccessService tenantAccessService; private WebTestClient webTestClient; private AdminController controller; + private TenantContext dummyContext; @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(); + dummyContext = new TenantContext("tenant1", "user1", java.util.Set.of("admin:read", "admin:write")); } @Test @@ -34,9 +43,11 @@ 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)); + when(tenantAccessService.require(any(), eq(TenantPermissions.ADMIN_READ))).thenReturn(dummyContext); webTestClient.get() .uri("/api/v1/admin/convert/jobs") + .header("X-Clearfolio-Tenant-Id", "tenant1") .exchange() .expectStatus().isOk() .expectBody() @@ -52,9 +63,11 @@ void getAllJobsFiltersByDeadLetteredTrue() { ConversionJob job2 = new ConversionJob(UUID.randomUUID(), "b.pdf", "application/pdf", "hash-b", 100L); when(conversionService.getAllJobs()).thenReturn(Arrays.asList(job1, job2)); + when(tenantAccessService.require(any(), eq(TenantPermissions.ADMIN_READ))).thenReturn(dummyContext); webTestClient.get() .uri("/api/v1/admin/convert/jobs?deadLettered=true") + .header("X-Clearfolio-Tenant-Id", "tenant1") .exchange() .expectStatus().isOk() .expectBody() @@ -69,9 +82,11 @@ void getAllJobsFiltersByDeadLetteredFalse() { ConversionJob job2 = new ConversionJob(UUID.randomUUID(), "b.pdf", "application/pdf", "hash-b", 100L); when(conversionService.getAllJobs()).thenReturn(Arrays.asList(job1, job2)); + when(tenantAccessService.require(any(), eq(TenantPermissions.ADMIN_READ))).thenReturn(dummyContext); webTestClient.get() .uri("/api/v1/admin/convert/jobs?deadLettered=false") + .header("X-Clearfolio-Tenant-Id", "tenant1") .exchange() .expectStatus().isOk() .expectBody() @@ -82,9 +97,11 @@ void getAllJobsFiltersByDeadLetteredFalse() { @Test void deleteJobReturnsNoContent() { UUID jobId = UUID.randomUUID(); + when(tenantAccessService.require(any(), eq(TenantPermissions.ADMIN_WRITE))).thenReturn(dummyContext); webTestClient.delete() .uri("/api/v1/admin/convert/jobs/" + jobId) + .header("X-Clearfolio-Tenant-Id", "tenant1") .exchange() .expectStatus().isNoContent(); } @@ -93,9 +110,11 @@ void deleteJobReturnsNoContent() { void retryDeadLetteredReturnsAcceptedWhenAccepted() { UUID jobId = UUID.randomUUID(); when(conversionService.retryDeadLettered(jobId, "admin")).thenReturn(RetryDeadLetterResult.ACCEPTED); + when(tenantAccessService.require(any(), eq(TenantPermissions.ADMIN_WRITE))).thenReturn(dummyContext); webTestClient.post() .uri("/api/v1/admin/convert/jobs/" + jobId + "/retry") + .header("X-Clearfolio-Tenant-Id", "tenant1") .exchange() .expectStatus().isAccepted(); } @@ -104,9 +123,11 @@ void retryDeadLetteredReturnsAcceptedWhenAccepted() { void retryDeadLetteredReturnsNotFoundWhenNotFound() { UUID jobId = UUID.randomUUID(); when(conversionService.retryDeadLettered(jobId, "admin")).thenReturn(RetryDeadLetterResult.NOT_FOUND); + when(tenantAccessService.require(any(), eq(TenantPermissions.ADMIN_WRITE))).thenReturn(dummyContext); webTestClient.post() .uri("/api/v1/admin/convert/jobs/" + jobId + "/retry") + .header("X-Clearfolio-Tenant-Id", "tenant1") .exchange() .expectStatus().isNotFound(); } @@ -115,9 +136,11 @@ void retryDeadLetteredReturnsNotFoundWhenNotFound() { void retryDeadLetteredReturnsConflictWhenNotEligible() { UUID jobId = UUID.randomUUID(); when(conversionService.retryDeadLettered(jobId, "admin")).thenReturn(RetryDeadLetterResult.NOT_ELIGIBLE); + when(tenantAccessService.require(any(), eq(TenantPermissions.ADMIN_WRITE))).thenReturn(dummyContext); webTestClient.post() .uri("/api/v1/admin/convert/jobs/" + jobId + "/retry") + .header("X-Clearfolio-Tenant-Id", "tenant1") .exchange() .expectStatus().isEqualTo(409); // isConflict() isn't always available depending on spring-test version, so using isEqualTo(409) is safer } diff --git a/update_changelog.py b/update_changelog.py new file mode 100644 index 00000000..91d363bf --- /dev/null +++ b/update_changelog.py @@ -0,0 +1,14 @@ +import re + +with open('CHANGELOG.md', 'r') as f: + content = f.read() + +new_content = re.sub( + r'(### 추가된 기능 \(Added\))', + r'\1\n- **관리자 API 권한 검증 로직 추가**: `AdminController`의 엔드포인트에 `TenantAccessService`를 통한 `ADMIN_READ`, `ADMIN_WRITE` 권한 검증을 추가하여 보안을 강화했습니다.', + content, + count=1 +) + +with open('CHANGELOG.md', 'w') as f: + f.write(new_content)