diff --git a/.jules/sentinel.md b/.jules/sentinel.md
index e795cb9dd..d16a622aa 100644
--- a/.jules/sentinel.md
+++ b/.jules/sentinel.md
@@ -32,3 +32,15 @@
**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.
+## $(date +%Y-%m-%d) - [Admin Auth Bypass Fixed]
+**Vulnerability:** Missing authorization check on `AdminController` endpoints allowed unauthenticated access to admin-only APIs.
+**Learning:** Even internal or admin APIs require explicit injection and verification of `TenantAccessService` to prevent unauthorized actions and data leakage across boundaries.
+**Prevention:** Always mandate header injection and `TenantAccessService.require()` with explicitly defined role permissions (e.g. `ADMIN_READ`, `ADMIN_WRITE`) on all new API controllers.
+## $(date +%Y-%m-%d) - [Admin Tenant Filtering Missing]
+**Vulnerability:** Even though the `AdminController` was protected by requiring admin permissions (like `ADMIN_READ`), the `getAllJobs` and other endpoints failed to filter objects by the specific `tenantId` of the requesting admin.
+**Learning:** Checking that a user has a specific permission does not automatically scope the data they see to their tenant. `TenantAccessService` enforces the presence of the permission, but the controller or service layer must actively filter the returned resources by `tenantContext.tenantId()`.
+**Prevention:** Always pair functional authorization (`tenantAccessService.require(..., ADMIN_READ)`) with object-level or tenant-level data filtering in admin endpoints (e.g., verifying `job.belongsToTenant(context.tenantId())`).
+## $(date +%Y-%m-%d) - [Jackson Databind Vulnerabilities]
+**Vulnerability:** The project was using an outdated version of `jackson-databind` (via `jackson-bom`), which had known HIGH and MEDIUM severity vulnerabilities reported by Trivy (CVE-2026-68497, CVE-2026-19032, CVE-2026-83557).
+**Learning:** Outdated dependencies, especially ubiquitous libraries like `jackson-databind` which parse untrusted inputs, are frequent vectors for critical vulnerabilities (e.g., DoS, RCE via deserialization).
+**Prevention:** Regularly scan dependencies and bump the affected package or its managing BOM/parent as required by the security policy (`SECURITY.md`). Here we updated `jackson-bom.version` in `pom.xml`.
diff --git a/pom.xml b/pom.xml
index af3ec7ad2..549a49c00 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.18.10
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..75eff4770 100644
--- a/src/main/java/com/clearfolio/viewer/auth/TenantPermissions.java
+++ b/src/main/java/com/clearfolio/viewer/auth/TenantPermissions.java
@@ -55,6 +55,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 perform admin write operations.
+ */
+ 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..aa8f7b213 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,33 +31,39 @@
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 carrying tenant claims
* @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) {
+ TenantContext context = tenantAccessService.require(headers, TenantPermissions.ADMIN_READ);
Iterable allJobs = conversionService.getAllJobs();
- if (deadLettered == null) {
- return AdminJobListResponse.from(allJobs);
- }
-
List filtered = new ArrayList<>();
for (ConversionJob job : allJobs) {
- if (job.isDeadLettered() == deadLettered) {
+ if (!job.belongsToTenant(context.tenantId())) {
+ continue;
+ }
+ if (deadLettered == null || job.isDeadLettered() == deadLettered) {
filtered.add(job);
}
}
@@ -63,11 +74,15 @@ 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 UUID jobId, @RequestHeader HttpHeaders headers) {
+ TenantContext context = tenantAccessService.require(headers, TenantPermissions.ADMIN_WRITE);
+ if (!conversionService.deleteJob(jobId, context)) {
+ throw new ResponseStatusException(HttpStatus.NOT_FOUND, "job not found");
+ }
return ResponseEntity.noContent().build();
}
@@ -75,14 +90,16 @@ 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) {
+ public ResponseEntity retryDeadLettered(@PathVariable UUID jobId, @RequestHeader HttpHeaders headers) {
+ TenantContext context = tenantAccessService.require(headers, TenantPermissions.ADMIN_WRITE);
+ ConversionJob job = conversionService.getJob(jobId)
+ .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "job not found"));
+ tenantAccessService.requireSameTenant(context, job);
RetryDeadLetterResult result = conversionService.retryDeadLettered(jobId, "admin");
- if (result == RetryDeadLetterResult.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");
}
diff --git a/src/test/java/com/clearfolio/viewer/controller/AdminControllerTest.java b/src/test/java/com/clearfolio/viewer/controller/AdminControllerTest.java
index ad63a8015..0d97a1bea 100644
--- a/src/test/java/com/clearfolio/viewer/controller/AdminControllerTest.java
+++ b/src/test/java/com/clearfolio/viewer/controller/AdminControllerTest.java
@@ -1,15 +1,22 @@
package com.clearfolio.viewer.controller;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
import java.util.Arrays;
+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.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;
@@ -17,22 +24,30 @@
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();
}
+ private void mockAuth() {
+ TenantContext ctx = new TenantContext("tenant-1", "subject-1", Set.of(TenantPermissions.ADMIN_READ, TenantPermissions.ADMIN_WRITE));
+ when(tenantAccessService.require(any(HttpHeaders.class), anyString())).thenReturn(ctx);
+ }
+
@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);
+ mockAuth();
+ ConversionJob job1 = new ConversionJob(UUID.randomUUID(), "tenant-1", "subject-1", "a.pdf", "application/pdf", "hash-a", 100L, 3);
+ ConversionJob job2 = new ConversionJob(UUID.randomUUID(), "tenant-1", "subject-1", "b.pdf", "application/pdf", "hash-b", 100L, 3);
when(conversionService.getAllJobs()).thenReturn(Arrays.asList(job1, job2));
webTestClient.get()
@@ -45,11 +60,28 @@ void getAllJobsReturnsAllJobsWhenNoFilterProvided() {
.jsonPath("$.jobs[1].fileName").isEqualTo("b.pdf");
}
+ @Test
+ void getAllJobsFiltersByTenantId() {
+ mockAuth();
+ ConversionJob job1 = new ConversionJob(UUID.randomUUID(), "tenant-1", "subject-1", "a.pdf", "application/pdf", "hash-a", 100L, 3);
+ ConversionJob job2 = new ConversionJob(UUID.randomUUID(), "tenant-2", "subject-1", "b.pdf", "application/pdf", "hash-b", 100L, 3);
+ 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 getAllJobsFiltersByDeadLetteredTrue() {
- ConversionJob job1 = new ConversionJob(UUID.randomUUID(), "a.pdf", "application/pdf", "hash-a", 100L);
+ mockAuth();
+ ConversionJob job1 = new ConversionJob(UUID.randomUUID(), "tenant-1", "subject-1", "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-1", "b.pdf", "application/pdf", "hash-b", 100L, 3);
when(conversionService.getAllJobs()).thenReturn(Arrays.asList(job1, job2));
@@ -64,9 +96,10 @@ void getAllJobsFiltersByDeadLetteredTrue() {
@Test
void getAllJobsFiltersByDeadLetteredFalse() {
- ConversionJob job1 = new ConversionJob(UUID.randomUUID(), "a.pdf", "application/pdf", "hash-a", 100L);
+ mockAuth();
+ ConversionJob job1 = new ConversionJob(UUID.randomUUID(), "tenant-1", "subject-1", "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-1", "b.pdf", "application/pdf", "hash-b", 100L, 3);
when(conversionService.getAllJobs()).thenReturn(Arrays.asList(job1, job2));
@@ -81,7 +114,9 @@ void getAllJobsFiltersByDeadLetteredFalse() {
@Test
void deleteJobReturnsNoContent() {
+ mockAuth();
UUID jobId = UUID.randomUUID();
+ when(conversionService.deleteJob(any(UUID.class), any(TenantContext.class))).thenReturn(true);
webTestClient.delete()
.uri("/api/v1/admin/convert/jobs/" + jobId)
@@ -89,9 +124,24 @@ void deleteJobReturnsNoContent() {
.expectStatus().isNoContent();
}
+ @Test
+ void deleteJobReturnsNotFoundWhenWrongTenant() {
+ mockAuth();
+ UUID jobId = UUID.randomUUID();
+ when(conversionService.deleteJob(any(UUID.class), any(TenantContext.class))).thenReturn(false);
+
+ webTestClient.delete()
+ .uri("/api/v1/admin/convert/jobs/" + jobId)
+ .exchange()
+ .expectStatus().isNotFound();
+ }
+
@Test
void retryDeadLetteredReturnsAcceptedWhenAccepted() {
+ mockAuth();
UUID jobId = UUID.randomUUID();
+ ConversionJob job = new ConversionJob(jobId, "tenant-1", "subject-1", "a.pdf", "application/pdf", "hash", 100L, 3);
+ when(conversionService.getJob(jobId)).thenReturn(java.util.Optional.of(job));
when(conversionService.retryDeadLettered(jobId, "admin")).thenReturn(RetryDeadLetterResult.ACCEPTED);
webTestClient.post()
@@ -102,8 +152,24 @@ void retryDeadLetteredReturnsAcceptedWhenAccepted() {
@Test
void retryDeadLetteredReturnsNotFoundWhenNotFound() {
+ mockAuth();
+ UUID jobId = UUID.randomUUID();
+ when(conversionService.getJob(jobId)).thenReturn(java.util.Optional.empty());
+
+ webTestClient.post()
+ .uri("/api/v1/admin/convert/jobs/" + jobId + "/retry")
+ .exchange()
+ .expectStatus().isNotFound();
+ }
+
+ @Test
+ void retryDeadLetteredReturnsNotFoundWhenWrongTenant() {
+ mockAuth();
UUID jobId = UUID.randomUUID();
- when(conversionService.retryDeadLettered(jobId, "admin")).thenReturn(RetryDeadLetterResult.NOT_FOUND);
+ ConversionJob job = new ConversionJob(jobId, "tenant-2", "subject-1", "a.pdf", "application/pdf", "hash", 100L, 3);
+ when(conversionService.getJob(jobId)).thenReturn(java.util.Optional.of(job));
+ org.mockito.Mockito.doThrow(new org.springframework.web.server.ResponseStatusException(org.springframework.http.HttpStatus.NOT_FOUND))
+ .when(tenantAccessService).requireSameTenant(any(TenantContext.class), any(ConversionJob.class));
webTestClient.post()
.uri("/api/v1/admin/convert/jobs/" + jobId + "/retry")
@@ -113,7 +179,10 @@ void retryDeadLetteredReturnsNotFoundWhenNotFound() {
@Test
void retryDeadLetteredReturnsConflictWhenNotEligible() {
+ mockAuth();
UUID jobId = UUID.randomUUID();
+ ConversionJob job = new ConversionJob(jobId, "tenant-1", "subject-1", "a.pdf", "application/pdf", "hash", 100L, 3);
+ when(conversionService.getJob(jobId)).thenReturn(java.util.Optional.of(job));
when(conversionService.retryDeadLettered(jobId, "admin")).thenReturn(RetryDeadLetterResult.NOT_ELIGIBLE);
webTestClient.post()