diff --git a/.jules/sentinel.md b/.jules/sentinel.md
index e795cb9dd..d40f7ded0 100644
--- a/.jules/sentinel.md
+++ b/.jules/sentinel.md
@@ -32,3 +32,13 @@
**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-09-28 - [CRITICAL] AdminController 인증 및 인가 추가
+**Vulnerability:** The endpoints in `AdminController` (`/api/v1/admin/convert/jobs`, `/api/v1/admin/convert/jobs/{jobId}`, and `/api/v1/admin/convert/jobs/{jobId}/retry`) did not require authentication or authorization checks.
+**Learning:** Security measures must be applied to administrative endpoints to prevent unauthorized access and data modification, even if the API seems to be used only internally or by privileged tools.
+**Prevention:** Ensure all newly created controllers and endpoints implement `TenantAccessService.require` appropriately with clear permission mappings such as `ADMIN_READ` and `ADMIN_WRITE`.
+
+## 2026-09-30 - [CRITICAL] Update jackson-bom to address multiple CVEs
+**Vulnerability:** Trivy reported `CVE-2026-68497`, `CVE-2026-19032`, and `CVE-2026-83557` targeting `com.fasterxml.jackson.core:jackson-databind` in the dependency tree.
+**Learning:** Legacy dependencies like `jackson-bom` should be aggressively monitored and updated to prevent deserialization or related attacks.
+**Prevention:** Always bump `jackson-bom.version` in the root `pom.xml` dependency management section when critical CVEs emerge.
diff --git a/pom.xml b/pom.xml
index af3ec7ad2..1e5de5f50 100644
--- a/pom.xml
+++ b/pom.xml
@@ -43,7 +43,7 @@
OSV config contains a narrow temporary ignore because OSV Scanner
v2.3.8 still reports 2.22.1 even though the advisory text names it as
patched. -->
- 2.22.1
+ 2.22.3
1.5.35
diff --git a/src/main/java/com/clearfolio/viewer/auth/TenantPermissions.java b/src/main/java/com/clearfolio/viewer/auth/TenantPermissions.java
index 4f9c6a685..18d7c3773 100644
--- a/src/main/java/com/clearfolio/viewer/auth/TenantPermissions.java
+++ b/src/main/java/com/clearfolio/viewer/auth/TenantPermissions.java
@@ -55,6 +55,17 @@ public final class TenantPermissions {
*/
public static final String ANALYTICS_READ = "analytics:read";
+
+ /**
+ * Permission required to read administrative 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 412d4eb86..f72bbe83d 100644
--- a/src/main/java/com/clearfolio/viewer/controller/AdminController.java
+++ b/src/main/java/com/clearfolio/viewer/controller/AdminController.java
@@ -11,6 +11,11 @@
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestParam;
+import org.springframework.web.bind.annotation.RequestHeader;
+import org.springframework.http.HttpHeaders;
+import com.clearfolio.viewer.auth.TenantAccessService;
+import com.clearfolio.viewer.auth.TenantContext;
+import com.clearfolio.viewer.auth.TenantPermissions;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.server.ResponseStatusException;
@@ -26,13 +31,18 @@
public class AdminController {
private final DocumentConversionService conversionService;
+ private final TenantAccessService tenantAccessService;
/**
* Creates a controller for admin operations.
*
* @param conversionService conversion service
+ * @param tenantAccessService tenant and permission guard
*/
- public AdminController(DocumentConversionService conversionService) {
+ public AdminController(
+ final DocumentConversionService conversionService,
+ final TenantAccessService tenantAccessService) {
+ this.tenantAccessService = tenantAccessService;
this.conversionService = conversionService;
}
@@ -40,19 +50,19 @@ public AdminController(DocumentConversionService conversionService) {
* Retrieves all conversion jobs, optionally filtered by dead-letter status.
*
* @param deadLettered optional filter for dead-lettered jobs
+ * @param headers request headers carrying tenant claims
* @return list of conversion jobs
*/
@GetMapping("/api/v1/admin/convert/jobs")
- public AdminJobListResponse getAllJobs(@RequestParam(required = false) Boolean deadLettered) {
- Iterable allJobs = conversionService.getAllJobs();
-
- if (deadLettered == null) {
- return AdminJobListResponse.from(allJobs);
- }
-
- List filtered = new ArrayList<>();
- for (ConversionJob job : allJobs) {
- if (job.isDeadLettered() == deadLettered) {
+ public AdminJobListResponse getAllJobs(
+ @RequestParam(required = false) final Boolean deadLettered,
+ @RequestHeader final HttpHeaders headers) {
+ final TenantContext tenantContext =
+ tenantAccessService.require(headers, TenantPermissions.ADMIN_READ);
+ final List filtered = new ArrayList<>();
+ for (final ConversionJob job : conversionService.getAllJobs()) {
+ if (job.belongsToTenant(tenantContext.tenantId())
+ && (deadLettered == null || job.isDeadLettered() == deadLettered)) {
filtered.add(job);
}
}
@@ -63,11 +73,18 @@ public AdminJobListResponse getAllJobs(@RequestParam(required = false) Boolean d
* Deletes a conversion job.
*
* @param jobId conversion job identifier
+ * @param headers request headers carrying tenant claims
* @return no content on success
*/
@DeleteMapping("/api/v1/admin/convert/jobs/{jobId}")
- public ResponseEntity deleteJob(@PathVariable UUID jobId) {
- conversionService.deleteJob(jobId);
+ public ResponseEntity deleteJob(
+ @PathVariable final UUID jobId,
+ @RequestHeader final HttpHeaders headers) {
+ final TenantContext tenantContext =
+ tenantAccessService.require(headers, TenantPermissions.ADMIN_WRITE);
+ if (!conversionService.deleteJob(jobId, tenantContext)) {
+ throw new ResponseStatusException(HttpStatus.NOT_FOUND, "job not found");
+ }
return ResponseEntity.noContent().build();
}
@@ -75,11 +92,19 @@ public ResponseEntity deleteJob(@PathVariable UUID jobId) {
* Retries a dead-lettered conversion job.
*
* @param jobId conversion job identifier
+ * @param headers request headers carrying tenant claims
* @return accepted response on success
*/
@PostMapping("/api/v1/admin/convert/jobs/{jobId}/retry")
- public ResponseEntity retryDeadLettered(@PathVariable UUID jobId) {
- RetryDeadLetterResult result = conversionService.retryDeadLettered(jobId, "admin");
+ public ResponseEntity retryDeadLettered(
+ @PathVariable final UUID jobId,
+ @RequestHeader final HttpHeaders headers) {
+ final TenantContext tenantContext =
+ tenantAccessService.require(headers, TenantPermissions.ADMIN_WRITE);
+ final ConversionJob job = conversionService.getJob(jobId)
+ .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "job not found"));
+ tenantAccessService.requireSameTenant(tenantContext, job);
+ final 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..dd41f6efa 100644
--- a/src/test/java/com/clearfolio/viewer/controller/AdminControllerTest.java
+++ b/src/test/java/com/clearfolio/viewer/controller/AdminControllerTest.java
@@ -1,57 +1,82 @@
package com.clearfolio.viewer.controller;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.doThrow;
import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import java.util.Arrays;
+import java.util.Optional;
+import java.util.Set;
import java.util.UUID;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
+import org.springframework.http.HttpHeaders;
+import org.springframework.http.HttpStatus;
import org.springframework.test.web.reactive.server.WebTestClient;
+import org.springframework.web.server.ResponseStatusException;
+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 static final String TENANT_ID = "tenant-a";
+ private static final String OTHER_TENANT_ID = "tenant-b";
+ private static final String SUBJECT_ID = "operator-a";
+
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);
+ AdminController controller = new AdminController(conversionService, tenantAccessService);
webTestClient = WebTestClient.bindToController(controller)
.controllerAdvice(new ApiExceptionHandler())
.build();
}
@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));
+ void getAllJobsReturnsOnlyRequestTenantJobsWhenNoFilterProvided() {
+ TenantContext context = allow(TenantPermissions.ADMIN_READ);
+ ConversionJob owned = job(TENANT_ID, "a.pdf");
+ ConversionJob foreign = job(OTHER_TENANT_ID, "b.pdf");
+ when(conversionService.getAllJobs()).thenReturn(Arrays.asList(owned, foreign));
webTestClient.get()
.uri("/api/v1/admin/convert/jobs")
.exchange()
.expectStatus().isOk()
.expectBody()
- .jsonPath("$.jobs.length()").isEqualTo(2)
- .jsonPath("$.jobs[0].fileName").isEqualTo("a.pdf")
- .jsonPath("$.jobs[1].fileName").isEqualTo("b.pdf");
+ .jsonPath("$.jobs.length()").isEqualTo(1)
+ .jsonPath("$.jobs[0].fileName").isEqualTo("a.pdf");
+
+ verify(tenantAccessService)
+ .require(any(HttpHeaders.class), eq(TenantPermissions.ADMIN_READ));
+ verify(conversionService).getAllJobs();
+ assert context.tenantId().equals(TENANT_ID);
}
@Test
- void getAllJobsFiltersByDeadLetteredTrue() {
- ConversionJob job1 = new ConversionJob(UUID.randomUUID(), "a.pdf", "application/pdf", "hash-a", 100L);
- job1.markDeadLettered("failed");
- ConversionJob job2 = new ConversionJob(UUID.randomUUID(), "b.pdf", "application/pdf", "hash-b", 100L);
-
- when(conversionService.getAllJobs()).thenReturn(Arrays.asList(job1, job2));
+ void getAllJobsFiltersOwnedJobsByDeadLetteredTrue() {
+ allow(TenantPermissions.ADMIN_READ);
+ ConversionJob deadLettered = job(TENANT_ID, "a.pdf");
+ deadLettered.markDeadLettered("failed");
+ ConversionJob active = job(TENANT_ID, "b.pdf");
+ ConversionJob foreign = job(OTHER_TENANT_ID, "foreign.pdf");
+ foreign.markDeadLettered("failed");
+ when(conversionService.getAllJobs()).thenReturn(Arrays.asList(deadLettered, active, foreign));
webTestClient.get()
.uri("/api/v1/admin/convert/jobs?deadLettered=true")
@@ -62,13 +87,28 @@ void getAllJobsFiltersByDeadLetteredTrue() {
.jsonPath("$.jobs[0].fileName").isEqualTo("a.pdf");
}
+
@Test
- void getAllJobsFiltersByDeadLetteredFalse() {
- ConversionJob job1 = new ConversionJob(UUID.randomUUID(), "a.pdf", "application/pdf", "hash-a", 100L);
- job1.markDeadLettered("failed");
- ConversionJob job2 = new ConversionJob(UUID.randomUUID(), "b.pdf", "application/pdf", "hash-b", 100L);
+ void getAllJobsFiltersOwnedJobsByDeadLetteredTrueReturnsEmptyWhenNoneMatch() {
+ allow(TenantPermissions.ADMIN_READ);
+ ConversionJob active = job(TENANT_ID, "b.pdf");
+ when(conversionService.getAllJobs()).thenReturn(Arrays.asList(active));
+
+ webTestClient.get()
+ .uri("/api/v1/admin/convert/jobs?deadLettered=true")
+ .exchange()
+ .expectStatus().isOk()
+ .expectBody()
+ .jsonPath("$.jobs.length()").isEqualTo(0);
+ }
- when(conversionService.getAllJobs()).thenReturn(Arrays.asList(job1, job2));
+ @Test
+ void getAllJobsFiltersOwnedJobsByDeadLetteredFalse() {
+ allow(TenantPermissions.ADMIN_READ);
+ ConversionJob deadLettered = job(TENANT_ID, "a.pdf");
+ deadLettered.markDeadLettered("failed");
+ ConversionJob active = job(TENANT_ID, "b.pdf");
+ when(conversionService.getAllJobs()).thenReturn(Arrays.asList(deadLettered, active));
webTestClient.get()
.uri("/api/v1/admin/convert/jobs?deadLettered=false")
@@ -80,45 +120,224 @@ void getAllJobsFiltersByDeadLetteredFalse() {
}
@Test
- void deleteJobReturnsNoContent() {
+ void getAllJobsRejectsUnauthorizedWithoutCallingService() {
+ deny(TenantPermissions.ADMIN_READ, HttpStatus.UNAUTHORIZED);
+
+ webTestClient.get()
+ .uri("/api/v1/admin/convert/jobs")
+ .exchange()
+ .expectStatus().isUnauthorized();
+
+ verify(conversionService, never()).getAllJobs();
+ }
+
+ @Test
+ void getAllJobsRejectsForbiddenWithoutCallingService() {
+ deny(TenantPermissions.ADMIN_READ, HttpStatus.FORBIDDEN);
+
+ webTestClient.get()
+ .uri("/api/v1/admin/convert/jobs")
+ .exchange()
+ .expectStatus().isForbidden();
+
+ verify(conversionService, never()).getAllJobs();
+ }
+
+ @Test
+ void deleteJobUsesTenantScopedDelete() {
+ TenantContext context = allow(TenantPermissions.ADMIN_WRITE);
UUID jobId = UUID.randomUUID();
+ when(conversionService.deleteJob(jobId, context)).thenReturn(true);
webTestClient.delete()
.uri("/api/v1/admin/convert/jobs/" + jobId)
.exchange()
.expectStatus().isNoContent();
+
+ verify(conversionService).deleteJob(jobId, context);
+ verify(conversionService, never()).deleteJob(jobId);
+ }
+
+ @Test
+ void deleteJobHidesMissingOrForeignJob() {
+ TenantContext context = allow(TenantPermissions.ADMIN_WRITE);
+ UUID jobId = UUID.randomUUID();
+ when(conversionService.deleteJob(jobId, context)).thenReturn(false);
+
+ webTestClient.delete()
+ .uri("/api/v1/admin/convert/jobs/" + jobId)
+ .exchange()
+ .expectStatus().isNotFound();
+
+ verify(conversionService, never()).deleteJob(jobId);
}
@Test
- void retryDeadLetteredReturnsAcceptedWhenAccepted() {
+ void deleteJobRejectsUnauthorizedWithoutCallingService() {
+ deny(TenantPermissions.ADMIN_WRITE, HttpStatus.UNAUTHORIZED);
UUID jobId = UUID.randomUUID();
- when(conversionService.retryDeadLettered(jobId, "admin")).thenReturn(RetryDeadLetterResult.ACCEPTED);
+
+ webTestClient.delete()
+ .uri("/api/v1/admin/convert/jobs/" + jobId)
+ .exchange()
+ .expectStatus().isUnauthorized();
+
+ verify(conversionService, never()).deleteJob(eq(jobId), any(TenantContext.class));
+ verify(conversionService, never()).deleteJob(jobId);
+ }
+
+ @Test
+ void deleteJobRejectsForbiddenWithoutCallingService() {
+ deny(TenantPermissions.ADMIN_WRITE, HttpStatus.FORBIDDEN);
+ UUID jobId = UUID.randomUUID();
+
+ webTestClient.delete()
+ .uri("/api/v1/admin/convert/jobs/" + jobId)
+ .exchange()
+ .expectStatus().isForbidden();
+
+ verify(conversionService, never()).deleteJob(eq(jobId), any(TenantContext.class));
+ verify(conversionService, never()).deleteJob(jobId);
+ }
+
+ @Test
+ void retryDeadLetteredChecksOwnershipBeforeRetry() {
+ TenantContext context = allow(TenantPermissions.ADMIN_WRITE);
+ ConversionJob owned = job(TENANT_ID, "a.pdf");
+ UUID jobId = owned.getJobId();
+ when(conversionService.getJob(jobId)).thenReturn(Optional.of(owned));
+ when(conversionService.retryDeadLettered(jobId, "admin"))
+ .thenReturn(RetryDeadLetterResult.ACCEPTED);
webTestClient.post()
.uri("/api/v1/admin/convert/jobs/" + jobId + "/retry")
.exchange()
.expectStatus().isAccepted();
+
+ verify(tenantAccessService).requireSameTenant(context, owned);
+ verify(conversionService).retryDeadLettered(jobId, "admin");
}
@Test
- void retryDeadLetteredReturnsNotFoundWhenNotFound() {
+ void retryDeadLetteredReturnsNotFoundWhenJobIsMissing() {
+ when(tenantAccessService.require(any(HttpHeaders.class), eq(TenantPermissions.ADMIN_WRITE)))
+ .thenReturn(mock(TenantContext.class));
UUID jobId = UUID.randomUUID();
- when(conversionService.retryDeadLettered(jobId, "admin")).thenReturn(RetryDeadLetterResult.NOT_FOUND);
+ when(conversionService.getJob(jobId)).thenReturn(Optional.empty());
+
+ webTestClient.post()
+ .uri("/api/v1/admin/convert/jobs/" + jobId + "/retry")
+ .exchange()
+ .expectStatus().isNotFound();
+
+ verify(tenantAccessService, never())
+ .requireSameTenant(any(TenantContext.class), any(ConversionJob.class));
+ verify(conversionService, never()).retryDeadLettered(jobId, "admin");
+ }
+
+ @Test
+ void retryDeadLetteredHidesForeignTenantJob() {
+ TenantContext context = allow(TenantPermissions.ADMIN_WRITE);
+ ConversionJob foreign = job(OTHER_TENANT_ID, "foreign.pdf");
+ UUID jobId = foreign.getJobId();
+ when(conversionService.getJob(jobId)).thenReturn(Optional.of(foreign));
+ doThrow(new ResponseStatusException(HttpStatus.NOT_FOUND, "job not found"))
+ .when(tenantAccessService).requireSameTenant(context, foreign);
+
+ webTestClient.post()
+ .uri("/api/v1/admin/convert/jobs/" + jobId + "/retry")
+ .exchange()
+ .expectStatus().isNotFound();
+
+ verify(conversionService, never()).retryDeadLettered(jobId, "admin");
+ }
+
+
+
+ @Test
+ void retryDeadLetteredReturnsNotFoundWhenServiceReturnsNotFound() {
+ TenantContext context = allow(TenantPermissions.ADMIN_WRITE);
+ ConversionJob owned = job(TENANT_ID, "a.pdf");
+ UUID jobId = owned.getJobId();
+ when(conversionService.getJob(jobId)).thenReturn(Optional.of(owned));
+ when(conversionService.retryDeadLettered(jobId, "admin"))
+ .thenReturn(RetryDeadLetterResult.NOT_FOUND);
webTestClient.post()
.uri("/api/v1/admin/convert/jobs/" + jobId + "/retry")
.exchange()
.expectStatus().isNotFound();
+
+ verify(tenantAccessService).requireSameTenant(context, owned);
}
@Test
void retryDeadLetteredReturnsConflictWhenNotEligible() {
+ TenantContext context = allow(TenantPermissions.ADMIN_WRITE);
+ ConversionJob owned = job(TENANT_ID, "a.pdf");
+ UUID jobId = owned.getJobId();
+ when(conversionService.getJob(jobId)).thenReturn(Optional.of(owned));
+ when(conversionService.retryDeadLettered(jobId, "admin"))
+ .thenReturn(RetryDeadLetterResult.NOT_ELIGIBLE);
+
+ webTestClient.post()
+ .uri("/api/v1/admin/convert/jobs/" + jobId + "/retry")
+ .exchange()
+ .expectStatus().isEqualTo(409);
+
+ verify(tenantAccessService).requireSameTenant(context, owned);
+ }
+
+ @Test
+ void retryDeadLetteredRejectsUnauthorizedWithoutCallingService() {
+ deny(TenantPermissions.ADMIN_WRITE, HttpStatus.UNAUTHORIZED);
+ UUID jobId = UUID.randomUUID();
+
+ webTestClient.post()
+ .uri("/api/v1/admin/convert/jobs/" + jobId + "/retry")
+ .exchange()
+ .expectStatus().isUnauthorized();
+
+ verify(conversionService, never()).getJob(jobId);
+ verify(conversionService, never()).retryDeadLettered(jobId, "admin");
+ }
+
+ @Test
+ void retryDeadLetteredRejectsForbiddenWithoutCallingService() {
+ deny(TenantPermissions.ADMIN_WRITE, HttpStatus.FORBIDDEN);
UUID jobId = UUID.randomUUID();
- when(conversionService.retryDeadLettered(jobId, "admin")).thenReturn(RetryDeadLetterResult.NOT_ELIGIBLE);
webTestClient.post()
.uri("/api/v1/admin/convert/jobs/" + jobId + "/retry")
.exchange()
- .expectStatus().isEqualTo(409); // isConflict() isn't always available depending on spring-test version, so using isEqualTo(409) is safer
+ .expectStatus().isForbidden();
+
+ verify(conversionService, never()).getJob(jobId);
+ verify(conversionService, never()).retryDeadLettered(jobId, "admin");
+ }
+
+ private TenantContext allow(String permission) {
+ TenantContext context = new TenantContext(TENANT_ID, SUBJECT_ID, Set.of(permission));
+ when(tenantAccessService.require(any(HttpHeaders.class), eq(permission)))
+ .thenReturn(context);
+ return context;
+ }
+
+ private void deny(String permission, HttpStatus status) {
+ when(tenantAccessService.require(any(HttpHeaders.class), eq(permission)))
+ .thenThrow(new ResponseStatusException(status));
+ }
+
+ private ConversionJob job(String tenantId, String fileName) {
+ return new ConversionJob(
+ UUID.randomUUID(),
+ tenantId,
+ SUBJECT_ID,
+ fileName,
+ "application/pdf",
+ "hash-" + fileName,
+ 100L,
+ 3
+ );
}
}