Skip to content
5 changes: 5 additions & 0 deletions .jules/sentinel.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,3 +32,8 @@
**Vulnerability:** The document hashing routine in `DefaultDocumentConversionService` processed file streams without enforcing any maximum size limit on the bytes read. An attacker could exploit this by uploading a maliciously large stream (or exploiting a compression bomb if unzipping), exhausting system memory, CPU, or disk space (DoS).
**Learning:** Checking the declared file size (e.g., `file.getSize()`) in initial validation is not always sufficient if the input stream itself can be spoofed or dynamically expanded during reading. The actual bytes read must be verified against bounds continuously.
**Prevention:** Always enforce a strict, configurable size limit (e.g., `ConversionProperties.maxUploadSizeBytes`) within the `while` loop that reads from untrusted input streams. Track `totalRead` and throw an exception immediately if the limit is exceeded.

## 2026-08-13 - [CRITICAL] Admin API 인증 및 권한 검증 누락 수정
**Vulnerability:** `AdminController`의 관리자 전용 API(전체 작업 조회, 작업 삭제, 데드레터 재시도 등)에 대해 사용자 인증 및 권한(tenant:configure) 검증이 누락되어 누구나 관리자 기능을 호출할 수 있었습니다.
**Learning:** 새로운 컨트롤러 추가 시 기존의 `TenantAccessService`를 통한 AOP/필터 기반 공통 처리가 아닌, 각 메서드 내에서 명시적인 권한 요구(tenantAccessService.require) 방식을 사용하고 있어 휴먼 에러로 검증 코드가 쉽게 누락될 수 있었습니다.
**Prevention:** 모든 엔드포인트는 추가 시 반드시 권한 검증 로직이 포함되었는지 확인하고, 보안 문서에 정의된 권한(`docs/security/2026-07-02-auth-tenant-model.md`의 `tenant_admin` 권한)과 일치하는 상수(예: `TENANT_CONFIGURE`)를 적용해야 합니다.
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,11 @@ public final class TenantPermissions {
*/
public static final String ANALYTICS_READ = "analytics:read";

/**
* Permission required to configure tenant or access admin endpoints.
*/
public static final String TENANT_CONFIGURE = "tenant:configure";

private TenantPermissions() {
}
}
72 changes: 56 additions & 16 deletions src/main/java/com/clearfolio/viewer/controller/AdminController.java
Original file line number Diff line number Diff line change
Expand Up @@ -4,47 +4,66 @@
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.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;

/**
* Controller for admin-specific endpoints.
* Controller for tenant-scoped administrative conversion operations.
*/
@RestController
public class AdminController {

/** Conversion service. */
private final DocumentConversionService conversionService;

/** Tenant access service. */
private final TenantAccessService tenantAccessService;

/**
* Creates a controller for admin operations.
*
* @param conversionService conversion service
* @param conversionSvc conversion service
* @param tenantAccessSvc tenant access service
*/
public AdminController(DocumentConversionService conversionService) {
this.conversionService = conversionService;
public AdminController(final DocumentConversionService conversionSvc,
final TenantAccessService tenantAccessSvc) {
this.conversionService = conversionSvc;
this.tenantAccessService = tenantAccessSvc;
}

/**
* Retrieves all conversion jobs, optionally filtered by dead-letter status.
* Retrieves tenant-owned conversion jobs, optionally filtered by dead-letter status.
*
* @param deadLettered optional filter for dead-lettered jobs
* @return list of conversion jobs
* @param headers authenticated tenant claim headers
* @return tenant-scoped list of conversion jobs
*/
@GetMapping("/api/v1/admin/convert/jobs")
public AdminJobListResponse getAllJobs(@RequestParam(required = false) Boolean deadLettered) {
Iterable<ConversionJob> allJobs = conversionService.getAllJobs();
public AdminJobListResponse getAllJobs(
@RequestParam(required = false) final Boolean deadLettered,
@RequestHeader final HttpHeaders headers) {
TenantContext tenantContext = tenantAccessService.require(
headers,
TenantPermissions.TENANT_CONFIGURE
);
Iterable<ConversionJob> allJobs = conversionService.getAllJobs(tenantContext);

if (deadLettered == null) {
return AdminJobListResponse.from(allJobs);
Expand All @@ -60,31 +79,52 @@ public AdminJobListResponse getAllJobs(@RequestParam(required = false) Boolean d
}

/**
* Deletes a conversion job.
* Deletes a conversion job owned by the authenticated tenant.
*
* @param jobId conversion job identifier
* @param headers authenticated tenant claim headers
* @return no content on success
*/
@DeleteMapping("/api/v1/admin/convert/jobs/{jobId}")
public ResponseEntity<Void> deleteJob(@PathVariable UUID jobId) {
conversionService.deleteJob(jobId);
public ResponseEntity<Void> deleteJob(@PathVariable final UUID jobId,
@RequestHeader final HttpHeaders headers) {
TenantContext tenantContext = tenantAccessService.require(
headers,
TenantPermissions.TENANT_CONFIGURE
);
if (!conversionService.deleteJob(jobId, tenantContext)) {
throw new ResponseStatusException(HttpStatus.NOT_FOUND, "job not found");
}
return ResponseEntity.noContent().build();
}

/**
* Retries a dead-lettered conversion job.
* Retries a dead-lettered conversion job owned by the authenticated tenant.
*
* @param jobId conversion job identifier
* @param headers authenticated tenant claim headers
* @return accepted response on success
*/
@PostMapping("/api/v1/admin/convert/jobs/{jobId}/retry")
public ResponseEntity<Void> retryDeadLettered(@PathVariable UUID jobId) {
RetryDeadLetterResult result = conversionService.retryDeadLettered(jobId, "admin");
public ResponseEntity<Void> retryDeadLettered(
@PathVariable final UUID jobId,
@RequestHeader final HttpHeaders headers) {
TenantContext tenantContext = tenantAccessService.require(
headers,
TenantPermissions.TENANT_CONFIGURE
);
RetryDeadLetterResult result = conversionService.retryDeadLettered(
jobId,
tenantContext.subjectId(),
tenantContext
);
if (result == RetryDeadLetterResult.NOT_FOUND) {
throw new ResponseStatusException(HttpStatus.NOT_FOUND, "job not found");
throw new ResponseStatusException(
HttpStatus.NOT_FOUND, "job not found");
}
if (result == RetryDeadLetterResult.NOT_ELIGIBLE) {
throw new ResponseStatusException(HttpStatus.CONFLICT, "job is not eligible for retry");
throw new ResponseStatusException(
HttpStatus.CONFLICT, "job is not eligible for retry");
}
return ResponseEntity.accepted().build();
}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
package com.clearfolio.viewer.service;

import java.util.ArrayList;
import java.util.List;
import java.util.Optional;
import java.util.UUID;

Expand Down Expand Up @@ -60,6 +62,36 @@ default UUID submit(MultipartFile file, PolicyOverrideRequest overrideRequest, T
*/
RetryDeadLetterResult retryDeadLettered(UUID jobId, String operatorId);

/**
* Retries only a dead-lettered job owned by the supplied tenant.
*
* <p>The compatibility default checks ownership before delegating to the
* legacy mutation. Durable implementations should override this method with
* one storage-scoped, generation-fenced transition.</p>
*
* @param jobId conversion job identifier
* @param operatorId operator identifier that triggered the retry
* @param tenantContext authenticated tenant authority
* @return retry outcome, with missing and foreign jobs both concealed as
* {@link RetryDeadLetterResult#NOT_FOUND}
*/
default RetryDeadLetterResult retryDeadLettered(
UUID jobId,
String operatorId,
TenantContext tenantContext
) {
if (tenantContext == null) {
return RetryDeadLetterResult.NOT_FOUND;
}

Optional<ConversionJob> job = getJob(jobId);
if (job.isEmpty() || !job.get().belongsToTenant(tenantContext.tenantId())) {
return RetryDeadLetterResult.NOT_FOUND;
}

return retryDeadLettered(jobId, operatorId);
}

/**
* Deletes a conversion job owned by the supplied tenant context.
*
Expand Down Expand Up @@ -95,4 +127,30 @@ default boolean deleteJob(UUID jobId, TenantContext tenantContext) {
* @return an iterable of all conversion jobs
*/
Iterable<ConversionJob> getAllJobs();

/**
* Returns only jobs owned by the supplied authenticated tenant.
*
* <p>The compatibility default prevents cross-tenant publication even when
* an older adapter exposes only a global inventory. Durable implementations
* should override this method with a tenant predicate at the storage query
* boundary.</p>
*
* @param tenantContext authenticated tenant authority
* @return immutable snapshot of tenant-owned jobs, or an empty result when
* tenant authority is absent
*/
default Iterable<ConversionJob> getAllJobs(TenantContext tenantContext) {
if (tenantContext == null) {
return List.of();
}

List<ConversionJob> tenantJobs = new ArrayList<>();
for (ConversionJob job : getAllJobs()) {
if (job.belongsToTenant(tenantContext.tenantId())) {
tenantJobs.add(job);
}
}
return List.copyOf(tenantJobs);
}
Comment thread
seonghobae marked this conversation as resolved.
Resolved
}
Loading
Loading