Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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` 엔드포인트를 추가했습니다.
Expand Down
10 changes: 10 additions & 0 deletions src/main/java/com/clearfolio/viewer/auth/TenantPermissions.java
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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<ConversionJob> allJobs = conversionService.getAllJobs();

if (deadLettered == null) {
Expand All @@ -62,23 +71,27 @@ 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<Void> deleteJob(@PathVariable UUID jobId) {
public ResponseEntity<Void> deleteJob(@RequestHeader HttpHeaders headers, @PathVariable UUID jobId) {
tenantAccessService.require(headers, TenantPermissions.ADMIN_WRITE);
conversionService.deleteJob(jobId);
return ResponseEntity.noContent().build();
}

/**
* 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<Void> retryDeadLettered(@PathVariable UUID jobId) {
public ResponseEntity<Void> 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");
Expand Down
Original file line number Diff line number Diff line change
@@ -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;

Expand All @@ -10,33 +12,42 @@
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;

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
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()
Expand All @@ -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()
Expand All @@ -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()
Expand All @@ -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();
}
Expand All @@ -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();
}
Expand All @@ -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();
}
Expand All @@ -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
}
Expand Down
14 changes: 14 additions & 0 deletions update_changelog.py
Original file line number Diff line number Diff line change
@@ -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)
Loading