Skip to content
Closed
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
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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개 테스트 통과를 확인했습니다.
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 write admin data.
*/
public static final String ADMIN_WRITE = "admin:write";

private TenantPermissions() {
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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<ConversionJob> allJobs = conversionService.getAllJobs();

if (deadLettered == null) {
Expand All @@ -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<Void> deleteJob(@PathVariable UUID jobId) {
public ResponseEntity<Void> deleteJob(@PathVariable UUID jobId, @RequestHeader HttpHeaders headers) {
tenantAccessService.require(headers, TenantPermissions.ADMIN_WRITE);
conversionService.deleteJob(jobId);
return ResponseEntity.noContent().build();
}
Expand All @@ -75,10 +87,12 @@ public ResponseEntity<Void> 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<Void> retryDeadLettered(@PathVariable UUID jobId) {
public ResponseEntity<Void> 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");
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,20 +10,24 @@
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;

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();
Expand All @@ -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
Expand All @@ -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
Expand All @@ -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
Expand All @@ -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
Expand All @@ -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
Expand All @@ -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
Expand All @@ -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)
);
}
}
Loading