diff --git a/src/main/java/com/clearfolio/viewer/controller/AdminController.java b/src/main/java/com/clearfolio/viewer/controller/AdminController.java index 412d4eb8..b4748343 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; @@ -25,35 +30,43 @@ @RestController public class AdminController { + private static final java.util.HexFormat HEX_FORMAT = java.util.HexFormat.of(); 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( + final DocumentConversionService conversionService, + final 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) { - Iterable allJobs = conversionService.getAllJobs(); + public AdminJobListResponse getAllJobs( + @RequestParam(required = false) final Boolean deadLettered, + @RequestHeader final HttpHeaders headers) { + final TenantContext tenantContext = tenantAccessService.require(headers, TenantPermissions.JOB_READ); + final Iterable allJobs = conversionService.getAllJobs(); - if (deadLettered == null) { - return AdminJobListResponse.from(allJobs); - } - - List filtered = new ArrayList<>(); - for (ConversionJob job : allJobs) { - if (job.isDeadLettered() == deadLettered) { - filtered.add(job); + final List filtered = new ArrayList<>(); + for (final ConversionJob job : allJobs) { + if (job.belongsToTenant(tenantContext.tenantId())) { + if (deadLettered == null || job.isDeadLettered() == deadLettered) { + filtered.add(job); + } } } return AdminJobListResponse.from(filtered); @@ -63,10 +76,18 @@ 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 deleteJob(@PathVariable UUID jobId) { + public ResponseEntity deleteJob( + @PathVariable final UUID jobId, + @RequestHeader final HttpHeaders headers) { + final TenantContext tenantContext = tenantAccessService.require(headers, TenantPermissions.JOB_DELETE); + final ConversionJob job = conversionService.getJob(jobId) + .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "job not found")); + tenantAccessService.requireSameTenant(tenantContext, job); + conversionService.deleteJob(jobId); return ResponseEntity.noContent().build(); } @@ -75,11 +96,34 @@ public ResponseEntity 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 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.JOB_RETRY); + final ConversionJob job = conversionService.getJob(jobId) + .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "job not found")); + tenantAccessService.requireSameTenant(tenantContext, job); + + final String operatorId = tenantContext.subjectId(); + if (operatorId == null || operatorId.isBlank()) { + throw new IllegalArgumentException("operator id is required"); + } + + // Pseudonymize operator identity using SHA-256 to avoid leaking raw subject ids to the backend layer. + final String hashedOperator; + try { + final java.security.MessageDigest digest = java.security.MessageDigest.getInstance("SHA-256"); + final byte[] hash = digest.digest(operatorId.getBytes(java.nio.charset.StandardCharsets.UTF_8)); + hashedOperator = HEX_FORMAT.formatHex(hash); + } catch (final java.security.NoSuchAlgorithmException ex) { + throw new IllegalStateException("SHA-256 algorithm not available", ex); + } + + final RetryDeadLetterResult result = conversionService.retryDeadLettered(jobId, hashedOperator); 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 ad63a801..6620b52b 100644 --- a/src/test/java/com/clearfolio/viewer/controller/AdminControllerTest.java +++ b/src/test/java/com/clearfolio/viewer/controller/AdminControllerTest.java @@ -1,38 +1,56 @@ 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; +import java.security.Security; import java.util.Arrays; +import java.util.List; +import java.util.Optional; 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.model.ConversionJob; import com.clearfolio.viewer.service.DocumentConversionService; import com.clearfolio.viewer.service.RetryDeadLetterResult; +import com.clearfolio.viewer.testsupport.SecurityProviderTestSupport; class AdminControllerTest { private DocumentConversionService conversionService; + private TenantAccessService tenantAccessService; private WebTestClient webTestClient; private AdminController controller; + private TenantContext tenantContext; @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(); + + tenantContext = mock(TenantContext.class); + when(tenantContext.tenantId()).thenReturn("tenant-1"); + when(tenantContext.subjectId()).thenReturn("subject-1"); } @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(tenantAccessService.require(any(HttpHeaders.class), eq(TenantPermissions.JOB_READ))).thenReturn(tenantContext); + ConversionJob job1 = new ConversionJob(UUID.randomUUID(), "tenant-1", "subject", "a.pdf", "application/pdf", "hash-a", 100L, 3); + ConversionJob job2 = new ConversionJob(UUID.randomUUID(), "tenant-1", "subject", "b.pdf", "application/pdf", "hash-b", 100L, 3); when(conversionService.getAllJobs()).thenReturn(Arrays.asList(job1, job2)); webTestClient.get() @@ -47,9 +65,10 @@ void getAllJobsReturnsAllJobsWhenNoFilterProvided() { @Test void getAllJobsFiltersByDeadLetteredTrue() { - ConversionJob job1 = new ConversionJob(UUID.randomUUID(), "a.pdf", "application/pdf", "hash-a", 100L); + when(tenantAccessService.require(any(HttpHeaders.class), eq(TenantPermissions.JOB_READ))).thenReturn(tenantContext); + ConversionJob job1 = new ConversionJob(UUID.randomUUID(), "tenant-1", "subject", "a.pdf", "application/pdf", "hash-a", 100L, 3); job1.markDeadLettered("failed"); - ConversionJob job2 = new ConversionJob(UUID.randomUUID(), "b.pdf", "application/pdf", "hash-b", 100L); + ConversionJob job2 = new ConversionJob(UUID.randomUUID(), "tenant-1", "subject", "b.pdf", "application/pdf", "hash-b", 100L, 3); when(conversionService.getAllJobs()).thenReturn(Arrays.asList(job1, job2)); @@ -64,9 +83,10 @@ void getAllJobsFiltersByDeadLetteredTrue() { @Test void getAllJobsFiltersByDeadLetteredFalse() { - ConversionJob job1 = new ConversionJob(UUID.randomUUID(), "a.pdf", "application/pdf", "hash-a", 100L); + when(tenantAccessService.require(any(HttpHeaders.class), eq(TenantPermissions.JOB_READ))).thenReturn(tenantContext); + ConversionJob job1 = new ConversionJob(UUID.randomUUID(), "tenant-1", "subject", "a.pdf", "application/pdf", "hash-a", 100L, 3); job1.markDeadLettered("failed"); - ConversionJob job2 = new ConversionJob(UUID.randomUUID(), "b.pdf", "application/pdf", "hash-b", 100L); + ConversionJob job2 = new ConversionJob(UUID.randomUUID(), "tenant-1", "subject", "b.pdf", "application/pdf", "hash-b", 100L, 3); when(conversionService.getAllJobs()).thenReturn(Arrays.asList(job1, job2)); @@ -79,9 +99,28 @@ void getAllJobsFiltersByDeadLetteredFalse() { .jsonPath("$.jobs[0].fileName").isEqualTo("b.pdf"); } + @Test + void getAllJobsHidesCrossTenantJobs() { + when(tenantAccessService.require(any(HttpHeaders.class), eq(TenantPermissions.JOB_READ))).thenReturn(tenantContext); + ConversionJob job1 = new ConversionJob(UUID.randomUUID(), "tenant-1", "subject", "a.pdf", "application/pdf", "hash-a", 100L, 3); + ConversionJob job2 = new ConversionJob(UUID.randomUUID(), "tenant-2", "subject", "b.pdf", "application/pdf", "hash-b", 100L, 3); // different tenant + when(conversionService.getAllJobs()).thenReturn(Arrays.asList(job1, job2)); + + webTestClient.get() + .uri("/api/v1/admin/convert/jobs") + .exchange() + .expectStatus().isOk() + .expectBody() + .jsonPath("$.jobs.length()").isEqualTo(1) + .jsonPath("$.jobs[0].fileName").isEqualTo("a.pdf"); + } + @Test void deleteJobReturnsNoContent() { + when(tenantAccessService.require(any(HttpHeaders.class), eq(TenantPermissions.JOB_DELETE))).thenReturn(tenantContext); UUID jobId = UUID.randomUUID(); + ConversionJob job = new ConversionJob(jobId, "tenant-1", "subject", "a.pdf", "application/pdf", "hash-a", 100L, 3); + when(conversionService.getJob(jobId)).thenReturn(Optional.of(job)); webTestClient.delete() .uri("/api/v1/admin/convert/jobs/" + jobId) @@ -89,10 +128,25 @@ void deleteJobReturnsNoContent() { .expectStatus().isNoContent(); } + @Test + void deleteJobReturnsNotFoundWhenJobDoesNotExist() { + when(tenantAccessService.require(any(HttpHeaders.class), eq(TenantPermissions.JOB_DELETE))).thenReturn(tenantContext); + UUID jobId = UUID.randomUUID(); + when(conversionService.getJob(jobId)).thenReturn(Optional.empty()); + + webTestClient.delete() + .uri("/api/v1/admin/convert/jobs/" + jobId) + .exchange() + .expectStatus().isNotFound(); + } + @Test void retryDeadLetteredReturnsAcceptedWhenAccepted() { + when(tenantAccessService.require(any(HttpHeaders.class), eq(TenantPermissions.JOB_RETRY))).thenReturn(tenantContext); UUID jobId = UUID.randomUUID(); - when(conversionService.retryDeadLettered(jobId, "admin")).thenReturn(RetryDeadLetterResult.ACCEPTED); + ConversionJob job = new ConversionJob(jobId, "tenant-1", "subject", "a.pdf", "application/pdf", "hash-a", 100L, 3); + when(conversionService.getJob(jobId)).thenReturn(Optional.of(job)); + when(conversionService.retryDeadLettered(eq(jobId), any(String.class))).thenReturn(RetryDeadLetterResult.ACCEPTED); webTestClient.post() .uri("/api/v1/admin/convert/jobs/" + jobId + "/retry") @@ -100,10 +154,25 @@ void retryDeadLetteredReturnsAcceptedWhenAccepted() { .expectStatus().isAccepted(); } + @Test + void retryDeadLetteredReturnsNotFoundWhenJobDoesNotExist() { + when(tenantAccessService.require(any(HttpHeaders.class), eq(TenantPermissions.JOB_RETRY))).thenReturn(tenantContext); + UUID jobId = UUID.randomUUID(); + when(conversionService.getJob(jobId)).thenReturn(Optional.empty()); + + webTestClient.post() + .uri("/api/v1/admin/convert/jobs/" + jobId + "/retry") + .exchange() + .expectStatus().isNotFound(); + } + @Test void retryDeadLetteredReturnsNotFoundWhenNotFound() { + when(tenantAccessService.require(any(HttpHeaders.class), eq(TenantPermissions.JOB_RETRY))).thenReturn(tenantContext); UUID jobId = UUID.randomUUID(); - when(conversionService.retryDeadLettered(jobId, "admin")).thenReturn(RetryDeadLetterResult.NOT_FOUND); + ConversionJob job = new ConversionJob(jobId, "tenant-1", "subject", "a.pdf", "application/pdf", "hash-a", 100L, 3); + when(conversionService.getJob(jobId)).thenReturn(Optional.of(job)); + when(conversionService.retryDeadLettered(eq(jobId), any(String.class))).thenReturn(RetryDeadLetterResult.NOT_FOUND); webTestClient.post() .uri("/api/v1/admin/convert/jobs/" + jobId + "/retry") @@ -113,12 +182,76 @@ void retryDeadLetteredReturnsNotFoundWhenNotFound() { @Test void retryDeadLetteredReturnsConflictWhenNotEligible() { + when(tenantAccessService.require(any(HttpHeaders.class), eq(TenantPermissions.JOB_RETRY))).thenReturn(tenantContext); + UUID jobId = UUID.randomUUID(); + ConversionJob job = new ConversionJob(jobId, "tenant-1", "subject", "a.pdf", "application/pdf", "hash-a", 100L, 3); + when(conversionService.getJob(jobId)).thenReturn(Optional.of(job)); + when(conversionService.retryDeadLettered(eq(jobId), any(String.class))).thenReturn(RetryDeadLetterResult.NOT_ELIGIBLE); + + webTestClient.post() + .uri("/api/v1/admin/convert/jobs/" + jobId + "/retry") + .exchange() + .expectStatus().isEqualTo(409); + } + + @Test + void retryDeadLetteredThrowsWhenOperatorIdBlank() { + TenantContext blankContext = mock(TenantContext.class); + when(blankContext.tenantId()).thenReturn("tenant-1"); + when(blankContext.subjectId()).thenReturn(""); + + when(tenantAccessService.require(any(HttpHeaders.class), eq(TenantPermissions.JOB_RETRY))).thenReturn(blankContext); + UUID jobId = UUID.randomUUID(); + ConversionJob job = new ConversionJob(jobId, "tenant-1", "subject", "a.pdf", "application/pdf", "hash-a", 100L, 3); + when(conversionService.getJob(jobId)).thenReturn(Optional.of(job)); + + webTestClient.post() + .uri("/api/v1/admin/convert/jobs/" + jobId + "/retry") + .exchange() + .expectStatus().isBadRequest(); + } + + @Test + void retryDeadLetteredThrowsWhenOperatorIdNull() { + TenantContext nullContext = mock(TenantContext.class); + when(nullContext.tenantId()).thenReturn("tenant-1"); + when(nullContext.subjectId()).thenReturn(null); + + when(tenantAccessService.require(any(HttpHeaders.class), eq(TenantPermissions.JOB_RETRY))).thenReturn(nullContext); UUID jobId = UUID.randomUUID(); - when(conversionService.retryDeadLettered(jobId, "admin")).thenReturn(RetryDeadLetterResult.NOT_ELIGIBLE); + ConversionJob job = new ConversionJob(jobId, "tenant-1", "subject", "a.pdf", "application/pdf", "hash-a", 100L, 3); + when(conversionService.getJob(jobId)).thenReturn(Optional.of(job)); 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().isBadRequest(); + } + + @Test + void retryDeadLetteredThrowsWhenSha256NotAvailable() { + when(tenantAccessService.require(any(HttpHeaders.class), eq(TenantPermissions.JOB_RETRY))).thenReturn(tenantContext); + UUID jobId = UUID.randomUUID(); + ConversionJob job = new ConversionJob(jobId, "tenant-1", "subject", "a.pdf", "application/pdf", "hash-a", 100L, 3); + when(conversionService.getJob(jobId)).thenReturn(Optional.of(job)); + + synchronized (SecurityProviderTestSupport.SECURITY_PROVIDERS_LOCK) { + List originalProviders = SecurityProviderTestSupport.sha256ProviderPositions(); + try { + for (SecurityProviderTestSupport.ProviderPosition pos : originalProviders) { + Security.removeProvider(pos.provider().getName()); + } + + webTestClient.post() + .uri("/api/v1/admin/convert/jobs/" + jobId + "/retry") + .exchange() + .expectStatus().is5xxServerError(); + + } finally { + for (SecurityProviderTestSupport.ProviderPosition pos : originalProviders) { + Security.insertProviderAt(pos.provider(), pos.position()); + } + } + } } }