From 110bf80220b2783e73b496b4edfd54d119c17e06 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 10 Aug 2026 23:38:44 +0900 Subject: [PATCH 1/4] test(security): expose unscoped admin delete boundary --- ...minControllerDeleteTenantBoundaryTest.java | 107 ++++++++++++++++++ 1 file changed, 107 insertions(+) create mode 100644 src/test/java/com/clearfolio/viewer/controller/AdminControllerDeleteTenantBoundaryTest.java diff --git a/src/test/java/com/clearfolio/viewer/controller/AdminControllerDeleteTenantBoundaryTest.java b/src/test/java/com/clearfolio/viewer/controller/AdminControllerDeleteTenantBoundaryTest.java new file mode 100644 index 000000000..cdfead1bf --- /dev/null +++ b/src/test/java/com/clearfolio/viewer/controller/AdminControllerDeleteTenantBoundaryTest.java @@ -0,0 +1,107 @@ +package com.clearfolio.viewer.controller; + +import static org.mockito.ArgumentMatchers.argThat; +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.UUID; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.http.HttpHeaders; +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.service.DocumentConversionService; + +class AdminControllerDeleteTenantBoundaryTest { + + private DocumentConversionService conversionService; + private WebTestClient webTestClient; + + @BeforeEach + void setUp() { + conversionService = mock(DocumentConversionService.class); + AdminController controller = new AdminController(conversionService, new TenantAccessService()); + webTestClient = WebTestClient.bindToController(controller) + .controllerAdvice(new ApiExceptionHandler()) + .build(); + } + + @Test + void deleteRejectsMissingTenantAuthorityBeforeMutation() { + UUID jobId = UUID.randomUUID(); + + webTestClient.delete() + .uri("/api/v1/admin/convert/jobs/" + jobId) + .exchange() + .expectStatus().isUnauthorized(); + + verify(conversionService, never()).deleteJob(jobId); + } + + @Test + void deleteRejectsMissingDeletePermissionBeforeMutation() { + UUID jobId = UUID.randomUUID(); + + webTestClient.delete() + .uri("/api/v1/admin/convert/jobs/" + jobId) + .headers(headers -> authorizedHeaders(headers, TenantPermissions.JOB_READ)) + .exchange() + .expectStatus().isForbidden(); + + verify(conversionService, never()).deleteJob(jobId); + } + + @Test + void deleteUsesTenantScopedMutationAndConcealsMissingOrForeignJobs() { + UUID jobId = UUID.randomUUID(); + when(conversionService.deleteJob( + org.mockito.ArgumentMatchers.eq(jobId), + argThat(context -> context != null && "tenant-a".equals(context.tenantId())) + )).thenReturn(false); + + webTestClient.delete() + .uri("/api/v1/admin/convert/jobs/" + jobId) + .headers(headers -> authorizedHeaders(headers, TenantPermissions.JOB_DELETE)) + .exchange() + .expectStatus().isNotFound(); + + verify(conversionService).deleteJob( + org.mockito.ArgumentMatchers.eq(jobId), + argThat(context -> context != null && "tenant-a".equals(context.tenantId())) + ); + verify(conversionService, never()).deleteJob(jobId); + } + + @Test + void deleteReturnsNoContentOnlyAfterOwnedTenantScopedMutation() { + UUID jobId = UUID.randomUUID(); + when(conversionService.deleteJob( + org.mockito.ArgumentMatchers.eq(jobId), + argThat(context -> context != null && "tenant-a".equals(context.tenantId())) + )).thenReturn(true); + + webTestClient.delete() + .uri("/api/v1/admin/convert/jobs/" + jobId) + .headers(headers -> authorizedHeaders(headers, TenantPermissions.JOB_DELETE)) + .exchange() + .expectStatus().isNoContent(); + + verify(conversionService).deleteJob( + org.mockito.ArgumentMatchers.eq(jobId), + argThat(context -> context != null && "tenant-a".equals(context.tenantId())) + ); + verify(conversionService, never()).deleteJob(jobId); + } + + private static void authorizedHeaders(HttpHeaders headers, String permission) { + headers.set(TenantContext.TENANT_ID_HEADER, "tenant-a"); + headers.set(TenantContext.SUBJECT_ID_HEADER, "operator-a"); + headers.set(TenantContext.PERMISSIONS_HEADER, permission); + } +} From 8e2c50f5aaa2be17d57fd7e74847aabec98bedaa Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 10 Aug 2026 23:41:18 +0900 Subject: [PATCH 2/4] fix(security): scope admin deletion to authenticated tenant --- .../viewer/controller/AdminController.java | 26 ++++++++++++++++--- 1 file changed, 22 insertions(+), 4 deletions(-) diff --git a/src/main/java/com/clearfolio/viewer/controller/AdminController.java b/src/main/java/com/clearfolio/viewer/controller/AdminController.java index 412d4eb86..c8d9c89b2 100644 --- a/src/main/java/com/clearfolio/viewer/controller/AdminController.java +++ b/src/main/java/com/clearfolio/viewer/controller/AdminController.java @@ -4,17 +4,22 @@ 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; @@ -26,14 +31,20 @@ public class AdminController { private final DocumentConversionService conversionService; + private final TenantAccessService tenantAccessService; /** * Creates a controller for admin operations. * * @param conversionService conversion service + * @param tenantAccessService tenant authorization service */ - public AdminController(DocumentConversionService conversionService) { + public AdminController( + DocumentConversionService conversionService, + TenantAccessService tenantAccessService + ) { this.conversionService = conversionService; + this.tenantAccessService = tenantAccessService; } /** @@ -60,14 +71,21 @@ 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 tenant and permission claim headers * @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 UUID jobId, + @RequestHeader HttpHeaders headers + ) { + TenantContext context = tenantAccessService.require(headers, TenantPermissions.JOB_DELETE); + if (!conversionService.deleteJob(jobId, context)) { + throw new ResponseStatusException(HttpStatus.NOT_FOUND, "job not found"); + } return ResponseEntity.noContent().build(); } From 5da7462b0b98cabc429ea922888ebb8b5a0ccf1c Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 10 Aug 2026 23:42:04 +0900 Subject: [PATCH 3/4] test(security): align admin controller regression with tenant authority --- .../viewer/controller/AdminControllerTest.java | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/src/test/java/com/clearfolio/viewer/controller/AdminControllerTest.java b/src/test/java/com/clearfolio/viewer/controller/AdminControllerTest.java index ad63a8015..66208693f 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; @@ -23,7 +28,7 @@ class AdminControllerTest { @BeforeEach void setUp() { conversionService = mock(DocumentConversionService.class); - controller = new AdminController(conversionService); + controller = new AdminController(conversionService, new TenantAccessService()); webTestClient = WebTestClient.bindToController(controller) .controllerAdvice(new ApiExceptionHandler()) .build(); @@ -82,9 +87,13 @@ void getAllJobsFiltersByDeadLetteredFalse() { @Test void deleteJobReturnsNoContent() { UUID jobId = UUID.randomUUID(); + when(conversionService.deleteJob(eq(jobId), any(TenantContext.class))).thenReturn(true); webTestClient.delete() .uri("/api/v1/admin/convert/jobs/" + jobId) + .header(TenantContext.TENANT_ID_HEADER, "tenant-a") + .header(TenantContext.SUBJECT_ID_HEADER, "operator-a") + .header(TenantContext.PERMISSIONS_HEADER, TenantPermissions.JOB_DELETE) .exchange() .expectStatus().isNoContent(); } From 8b8b72e389a1e38297b468b8ee4aa83d92b832d9 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 11 Aug 2026 00:02:15 +0900 Subject: [PATCH 4/4] test(security): prove auth denial has zero delete-service interaction --- .../controller/AdminControllerDeleteTenantBoundaryTest.java | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/test/java/com/clearfolio/viewer/controller/AdminControllerDeleteTenantBoundaryTest.java b/src/test/java/com/clearfolio/viewer/controller/AdminControllerDeleteTenantBoundaryTest.java index cdfead1bf..2df52685d 100644 --- a/src/test/java/com/clearfolio/viewer/controller/AdminControllerDeleteTenantBoundaryTest.java +++ b/src/test/java/com/clearfolio/viewer/controller/AdminControllerDeleteTenantBoundaryTest.java @@ -4,6 +4,7 @@ import static org.mockito.Mockito.mock; import static org.mockito.Mockito.never; import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; import static org.mockito.Mockito.when; import java.util.UUID; @@ -41,7 +42,7 @@ void deleteRejectsMissingTenantAuthorityBeforeMutation() { .exchange() .expectStatus().isUnauthorized(); - verify(conversionService, never()).deleteJob(jobId); + verifyNoInteractions(conversionService); } @Test @@ -54,7 +55,7 @@ void deleteRejectsMissingDeletePermissionBeforeMutation() { .exchange() .expectStatus().isForbidden(); - verify(conversionService, never()).deleteJob(jobId); + verifyNoInteractions(conversionService); } @Test