Set up document management for Stirling Engine (#6476)

# Description of Changes
Change Stirling Engine to support deleting documents automatically. This
happens both on user logout and after an amount of time specified by the
Java when ingesting a document (allowing for personal documents to have
short lifetimes but org documents to be left in the db with no expiry
date). Also sets up an [ACL
policy](https://en.wikipedia.org/wiki/Access-control_list) for the
documents so the database knows which users have access to which
documents. This is not fully implemented in the Java, so currently all
docs are treated as having a single owner, the uploader, but
theoretically when we need to support org storage, we shouldn't need to
change the db schema.
This commit is contained in:
James Brunton
2026-06-03 11:52:11 +00:00
committed by GitHub
parent 71633861d0
commit 1264f4cfed
48 changed files with 2039 additions and 411 deletions
@@ -5,6 +5,7 @@ import java.util.List;
import java.util.Map;
import java.util.concurrent.Executor;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.HttpStatus;
@@ -30,6 +31,7 @@ import lombok.extern.slf4j.Slf4j;
import stirling.software.common.model.job.ResultFile;
import stirling.software.common.service.JobOwnershipService;
import stirling.software.common.service.TaskManager;
import stirling.software.common.service.UserServiceInterface;
import stirling.software.proprietary.model.api.ai.AiWorkflowProgressEvent;
import stirling.software.proprietary.model.api.ai.AiWorkflowRequest;
import stirling.software.proprietary.model.api.ai.AiWorkflowResponse;
@@ -58,6 +60,7 @@ public class AiEngineController {
private final TaskManager taskManager;
private final JobOwnershipService jobOwnershipService;
private final AiEngineEndpointResolver endpointResolver;
private final UserServiceInterface userService;
/**
* SSE emitter timeout. Long enough to accommodate multi-gigabyte PDF workflows (OCR on a
@@ -74,7 +77,8 @@ public class AiEngineController {
@Qualifier("aiStreamExecutor") Executor aiStreamExecutor,
TaskManager taskManager,
JobOwnershipService jobOwnershipService,
AiEngineEndpointResolver endpointResolver) {
AiEngineEndpointResolver endpointResolver,
@Autowired(required = false) UserServiceInterface userService) {
this.aiEngineClient = aiEngineClient;
this.aiWorkflowService = aiWorkflowService;
this.objectMapper = objectMapper;
@@ -82,6 +86,11 @@ public class AiEngineController {
this.taskManager = taskManager;
this.jobOwnershipService = jobOwnershipService;
this.endpointResolver = endpointResolver;
this.userService = userService;
}
private String currentUserId() {
return userService != null ? userService.getCurrentUsername() : null;
}
@GetMapping("/health")
@@ -89,7 +98,7 @@ public class AiEngineController {
summary = "AI engine health check",
description = "Returns the health status of the AI engine including configured models")
public ResponseEntity<String> health() throws IOException {
String response = aiEngineClient.get("/health");
String response = aiEngineClient.get("/health", currentUserId());
return ResponseEntity.ok().contentType(MediaType.APPLICATION_JSON).body(response);
}
@@ -243,7 +252,7 @@ public class AiEngineController {
HttpStatus.BAD_REQUEST, "Request body must be a JSON object");
}
String forwardedBody = withEnabledEndpoints((ObjectNode) parsed);
String response = aiEngineClient.post("/api/v1/pdf/edit", forwardedBody);
String response = aiEngineClient.post("/api/v1/pdf/edit", forwardedBody, currentUserId());
return ResponseEntity.ok().contentType(MediaType.APPLICATION_JSON).body(response);
}
@@ -1,5 +1,6 @@
package stirling.software.proprietary.model.api.ai;
import java.time.Instant;
import java.util.List;
import lombok.AllArgsConstructor;
@@ -10,6 +11,12 @@ import lombok.NoArgsConstructor;
* Body for {@code POST /api/v1/documents} on the AI engine. Sent by Java when the engine reports
* {@code need_ingest} and the requested document's extracted content must be stored before the
* workflow can continue.
*
* <p>{@code ownerId} is the tenant the doc belongs to (a user for personal uploads, an org for
* shared content). {@code readPrincipals} is the explicit list of principals granted read access.
* {@code expiresAt} is when the engine's reaper should delete this doc; {@code null} means
* "persistent until explicit delete" (used for org-shared content). Java picks the value per doc;
* the engine does not default it.
*/
@Data
@NoArgsConstructor
@@ -21,4 +28,10 @@ public class AiDocumentIngestRequest {
private String source;
private List<AiPageText> pageText;
private String ownerId;
private List<String> readPrincipals;
private Instant expiresAt;
}
@@ -8,6 +8,8 @@ import java.util.List;
import java.util.Locale;
import org.springframework.core.io.Resource;
import org.springframework.security.authentication.AuthenticationTrustResolver;
import org.springframework.security.authentication.AuthenticationTrustResolverImpl;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.security.oauth2.client.authentication.OAuth2AuthenticationToken;
@@ -36,6 +38,7 @@ import stirling.software.proprietary.audit.Audited;
import stirling.software.proprietary.security.saml2.CertificateUtils;
import stirling.software.proprietary.security.saml2.CustomSaml2AuthenticatedPrincipal;
import stirling.software.proprietary.security.service.JwtServiceInterface;
import stirling.software.proprietary.service.AiUserDataService;
@Slf4j
@RequiredArgsConstructor
@@ -49,12 +52,22 @@ public class CustomLogoutSuccessHandler extends SimpleUrlLogoutSuccessHandler {
private final JwtServiceInterface jwtService;
private final AiUserDataService aiUserDataService;
private static final AuthenticationTrustResolver TRUST_RESOLVER =
new AuthenticationTrustResolverImpl();
@Override
@Audited(type = AuditEventType.USER_LOGOUT, level = AuditLevel.BASIC)
public void onLogoutSuccess(
HttpServletRequest request, HttpServletResponse response, Authentication authentication)
throws IOException {
String username = resolveUsername(request, authentication);
if (username != null) {
aiUserDataService.purgeUserDocuments(username);
}
if (!response.isCommitted()) {
if (authentication != null) {
if (authentication instanceof Saml2Authentication samlAuthentication) {
@@ -88,6 +101,26 @@ public class CustomLogoutSuccessHandler extends SimpleUrlLogoutSuccessHandler {
}
}
/**
* Pick the right name to purge under. JWT cookie wins if present and parseable; we fall through
* to whatever Spring handed us only when there's no cookie. Spring's anonymous principal is
* filtered out via {@link AuthenticationTrustResolver} so we don't purge under that
* pseudo-user.
*/
private String resolveUsername(HttpServletRequest request, Authentication authentication) {
if (jwtService != null) {
String fromCookie = jwtService.extractUsernameFromRequestAllowExpired(request);
if (fromCookie != null) {
return fromCookie;
}
}
if (authentication == null || TRUST_RESOLVER.isAnonymous(authentication)) {
return null;
}
String name = authentication.getName();
return (name != null && !name.isBlank()) ? name : null;
}
// Redirect for SAML2 authentication logout
private void getRedirect_saml2(
HttpServletRequest request,
@@ -93,6 +93,7 @@ public class SecurityConfiguration {
licenseSettingsService;
private final ClientRegistrationRepository clientRegistrationRepository;
private final PasswordEncoder passwordEncoder;
private final stirling.software.proprietary.service.AiUserDataService aiUserDataService;
public SecurityConfiguration(
PersistentLoginRepository persistentLoginRepository,
@@ -115,7 +116,8 @@ public class SecurityConfiguration {
OpenSaml5AuthenticationRequestResolver saml2AuthenticationRequestResolver,
@Autowired(required = false) ClientRegistrationRepository clientRegistrationRepository,
stirling.software.proprietary.service.UserLicenseSettingsService licenseSettingsService,
PasswordEncoder passwordEncoder) {
PasswordEncoder passwordEncoder,
stirling.software.proprietary.service.AiUserDataService aiUserDataService) {
this.userDetailsService = userDetailsService;
this.userService = userService;
this.loginEnabledValue = loginEnabledValue;
@@ -135,6 +137,7 @@ public class SecurityConfiguration {
this.clientRegistrationRepository = clientRegistrationRepository;
this.licenseSettingsService = licenseSettingsService;
this.passwordEncoder = passwordEncoder;
this.aiUserDataService = aiUserDataService;
}
/**
@@ -322,7 +325,10 @@ public class SecurityConfiguration {
.matcher("/logout"))
.logoutSuccessHandler(
new CustomLogoutSuccessHandler(
securityProperties, appConfig, jwtService))
securityProperties,
appConfig,
jwtService,
aiUserDataService))
.clearAuthentication(true)
.invalidateHttpSession(true)
.deleteCookies("JSESSIONID", "remember-me", "stirling_jwt"));
@@ -44,6 +44,7 @@ import stirling.software.proprietary.security.service.RefreshRateLimitService;
import stirling.software.proprietary.security.service.TotpService;
import stirling.software.proprietary.security.service.UserService;
import stirling.software.proprietary.security.util.DesktopClientUtils;
import stirling.software.proprietary.service.AiUserDataService;
/** REST API Controller for authentication operations. */
@RestController
@@ -62,6 +63,7 @@ public class AuthController {
private final RefreshRateLimitService refreshRateLimitService;
private final ApplicationProperties.Security securityProperties;
private final ApplicationProperties applicationProperties;
private final AiUserDataService aiUserDataService;
/**
* Login endpoint - replaces Supabase signInWithPassword
@@ -281,11 +283,13 @@ public class AuthController {
*/
@PreAuthorize("!hasAuthority('ROLE_DEMO_USER')")
@PostMapping("/logout")
public ResponseEntity<?> logout(HttpServletResponse response) {
public ResponseEntity<?> logout(HttpServletRequest request, HttpServletResponse response) {
try {
String username = jwtService.extractUsernameFromRequestAllowExpired(request);
SecurityContextHolder.clearContext();
aiUserDataService.purgeUserDocuments(username);
log.debug("User logged out successfully");
log.debug("User logged out successfully (username={})", username);
return ResponseEntity.ok(Map.of("message", "Logged out successfully"));
@@ -349,6 +349,21 @@ public class JwtService implements JwtServiceInterface {
return null;
}
@Override
public String extractUsernameFromRequestAllowExpired(HttpServletRequest request) {
try {
String token = extractToken(request);
if (token == null || token.isBlank()) {
return null;
}
String username = extractUsernameAllowExpired(token);
return (username != null && !username.isBlank()) ? username : null;
} catch (Exception e) {
log.debug("Could not extract username from request JWT: {}", e.getMessage());
return null;
}
}
@Override
public boolean isJwtEnabled() {
return v2Enabled;
@@ -93,6 +93,19 @@ public interface JwtServiceInterface {
*/
String extractToken(HttpServletRequest request);
/**
* Read the username off the request's JWT, allowing an expired token. Returns null when no
* token is present, the token can't be parsed, or the resulting username is blank.
*
* <p>Used by flows that need to identify the user without depending on {@code
* SecurityContextHolder} - for example logout, where the security filter chain may have left
* the anonymous principal in place by the time the handler runs.
*
* @param request HTTP servlet request
* @return username from the token, or null when one can't be safely derived
*/
String extractUsernameFromRequestAllowExpired(HttpServletRequest request);
/**
* Check if JWT authentication is enabled
*
@@ -43,22 +43,23 @@ public class AiEngineClient {
this.httpClient = httpClient;
}
public String post(String path, String jsonBody) throws IOException {
public String post(String path, String jsonBody, String userId) throws IOException {
ApplicationProperties.AiEngine config = applicationProperties.getAiEngine();
return postWithTimeout(path, jsonBody, Duration.ofSeconds(config.getTimeoutSeconds()));
return postWithTimeout(
path, jsonBody, Duration.ofSeconds(config.getTimeoutSeconds()), userId);
}
/**
* POST with an explicit per-call timeout, for heavy operations (e.g. RAG ingestion of a large
* document) that legitimately take longer than the default timeout.
*/
public String postLongRunning(String path, String jsonBody) throws IOException {
public String postLongRunning(String path, String jsonBody, String userId) throws IOException {
ApplicationProperties.AiEngine config = applicationProperties.getAiEngine();
return postWithTimeout(
path, jsonBody, Duration.ofSeconds(config.getLongRunningTimeoutSeconds()));
path, jsonBody, Duration.ofSeconds(config.getLongRunningTimeoutSeconds()), userId);
}
private String postWithTimeout(String path, String jsonBody, Duration timeout)
private String postWithTimeout(String path, String jsonBody, Duration timeout, String userId)
throws IOException {
ApplicationProperties.AiEngine config = applicationProperties.getAiEngine();
if (!config.isEnabled()) {
@@ -69,22 +70,32 @@ public class AiEngineClient {
String url = config.getUrl().stripTrailing() + path;
log.debug("Proxying AI engine request to {} (timeout {}s)", url, timeout.toSeconds());
HttpRequest request =
HttpRequest.Builder builder =
HttpRequest.newBuilder()
.uri(URI.create(url))
.header("Content-Type", "application/json")
.header("Accept", "application/json")
.timeout(timeout)
.POST(HttpRequest.BodyPublishers.ofString(jsonBody))
.build();
HttpResponse<String> response = sendRequest(request);
.POST(HttpRequest.BodyPublishers.ofString(jsonBody));
addUserHeader(builder, userId);
HttpResponse<String> response = sendRequest(builder.build());
log.debug("AI engine responded with status {}", response.statusCode());
checkResponseStatus(response);
return response.body();
}
/**
* Attach the X-User-Id header so the engine can scope per-user storage (RAG documents, search
* results) to the caller. Skipped when {@code userId} is blank: the engine treats the request
* as anonymous and refuses any route that requires tenancy.
*/
private static void addUserHeader(HttpRequest.Builder builder, String userId) {
if (userId != null && !userId.isBlank()) {
builder.header("X-User-Id", userId);
}
}
/**
* POST a JSON body and consume the response as a stream of NDJSON lines. Each line is passed to
* {@code lineConsumer} in arrival order; the call returns when the engine closes the stream.
@@ -94,7 +105,8 @@ public class AiEngineClient {
* practice line arrival keeps the connection logically alive: as long as the engine emits
* events, the work is progressing. Genuine engine hangs still hit the total timeout.
*/
public void streamPost(String path, String jsonBody, Consumer<String> lineConsumer)
public void streamPost(
String path, String jsonBody, String userId, Consumer<String> lineConsumer)
throws IOException {
ApplicationProperties.AiEngine config = applicationProperties.getAiEngine();
if (!config.isEnabled()) {
@@ -109,14 +121,15 @@ public class AiEngineClient {
url,
timeout.toSeconds());
HttpRequest request =
HttpRequest.Builder builder =
HttpRequest.newBuilder()
.uri(URI.create(url))
.header("Content-Type", "application/json")
.header("Accept", "application/x-ndjson")
.timeout(timeout)
.POST(HttpRequest.BodyPublishers.ofString(jsonBody))
.build();
.POST(HttpRequest.BodyPublishers.ofString(jsonBody));
addUserHeader(builder, userId);
HttpRequest request = builder.build();
HttpResponse<Stream<String>> response;
try {
@@ -149,7 +162,36 @@ public class AiEngineClient {
}
}
public String get(String path) throws IOException {
/**
* DELETE with no body. Used for purging the caller's RAG content on logout. Wraps the same
* error envelope as {@link #post} / {@link #get} so callers see a consistent set of {@code
* ResponseStatusException}s.
*/
public String delete(String path, String userId) throws IOException {
ApplicationProperties.AiEngine config = applicationProperties.getAiEngine();
if (!config.isEnabled()) {
throw new ResponseStatusException(
HttpStatus.SERVICE_UNAVAILABLE, "AI engine is not enabled");
}
String url = config.getUrl().stripTrailing() + path;
log.debug("Proxying AI engine DELETE request to {}", url);
HttpRequest.Builder builder =
HttpRequest.newBuilder()
.uri(URI.create(url))
.header("Accept", "application/json")
.timeout(Duration.ofSeconds(config.getTimeoutSeconds()))
.DELETE();
addUserHeader(builder, userId);
HttpResponse<String> response = sendRequest(builder.build());
log.debug("AI engine responded with status {}", response.statusCode());
checkResponseStatus(response);
return response.body();
}
public String get(String path, String userId) throws IOException {
ApplicationProperties.AiEngine config = applicationProperties.getAiEngine();
if (!config.isEnabled()) {
throw new ResponseStatusException(
@@ -159,15 +201,14 @@ public class AiEngineClient {
String url = config.getUrl().stripTrailing() + path;
log.debug("Proxying AI engine GET request to {}", url);
HttpRequest request =
HttpRequest.Builder builder =
HttpRequest.newBuilder()
.uri(URI.create(url))
.header("Accept", "application/json")
.timeout(Duration.ofSeconds(config.getTimeoutSeconds()))
.GET()
.build();
HttpResponse<String> response = sendRequest(request);
.GET();
addUserHeader(builder, userId);
HttpResponse<String> response = sendRequest(builder.build());
log.debug("AI engine responded with status {}", response.statusCode());
checkResponseStatus(response);
@@ -0,0 +1,50 @@
package stirling.software.proprietary.service;
import java.io.IOException;
import org.springframework.scheduling.annotation.Async;
import org.springframework.stereotype.Service;
import org.springframework.web.server.ResponseStatusException;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
/**
* Lifecycle hooks for a user's AI document data on the Python engine.
*
* <p>Today: cleanup on logout. The engine also runs a TTL reaper that catches sessions ended
* without a clean logout (tab close, JWT expiry, engine restart), so this service is the happy-path
* purge, not a hard guarantee. Calls are fire-and-forget on a background thread (Spring's default
* {@code @Async} executor) so an unavailable engine never delays the caller's response.
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class AiUserDataService {
private static final String PURGE_PATH = "/api/v1/documents/by-owner";
private final AiEngineClient aiEngineClient;
/**
* Tell the engine to delete every collection owned by {@code userId}: vector chunks, page text,
* ACL rows, and the owner row itself. Runs asynchronously so the calling thread (typically a
* logout handler) returns immediately; engine errors are logged on the worker thread and never
* propagated. The engine's TTL reaper backstops any miss within ~24h.
*/
@Async
public void purgeUserDocuments(String userId) {
if (userId == null || userId.isBlank()) {
log.debug("Skipping user document purge: no user id");
return;
}
try {
aiEngineClient.delete(PURGE_PATH, userId);
log.debug("Requested document purge for user {}", userId);
} catch (ResponseStatusException e) {
log.warn("AI engine refused document purge for {}: {}", userId, e.getReason());
} catch (IOException e) {
log.warn("Failed to purge documents for {}: {}", userId, e.getMessage());
}
}
}
@@ -1,6 +1,8 @@
package stirling.software.proprietary.service;
import java.io.IOException;
import java.time.Duration;
import java.time.Instant;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
@@ -9,6 +11,7 @@ import java.util.stream.Collectors;
import org.apache.commons.io.FilenameUtils;
import org.apache.pdfbox.pdmodel.PDDocument;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.core.io.FileSystemResource;
import org.springframework.core.io.Resource;
import org.springframework.http.HttpHeaders;
@@ -24,14 +27,15 @@ import org.springframework.web.multipart.MultipartFile;
import io.github.pixee.security.Filenames;
import lombok.Data;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import stirling.software.common.model.ApplicationProperties;
import stirling.software.common.service.CustomPDFDocumentFactory;
import stirling.software.common.service.FileStorage;
import stirling.software.common.service.InternalApiClient;
import stirling.software.common.service.InternalApiTimeoutException;
import stirling.software.common.service.ToolMetadataService;
import stirling.software.common.service.UserServiceInterface;
import stirling.software.common.util.ExceptionUtils;
import stirling.software.common.util.TempFile;
import stirling.software.common.util.TempFileManager;
@@ -49,6 +53,7 @@ import stirling.software.proprietary.model.api.ai.AiWorkflowProgressEvent;
import stirling.software.proprietary.model.api.ai.AiWorkflowRequest;
import stirling.software.proprietary.model.api.ai.AiWorkflowResponse;
import stirling.software.proprietary.model.api.ai.AiWorkflowResultFile;
import stirling.software.proprietary.security.util.DesktopClientUtils;
import stirling.software.proprietary.service.PdfContentExtractor.LoadedFile;
import stirling.software.proprietary.service.PdfContentExtractor.PdfContentResult;
import stirling.software.proprietary.service.PdfContentExtractor.WorkflowArtifact;
@@ -59,7 +64,6 @@ import tools.jackson.databind.ObjectMapper;
@Slf4j
@Service
@RequiredArgsConstructor
public class AiWorkflowService {
private static final String DOCUMENTS_ENDPOINT = "/api/v1/documents";
@@ -74,6 +78,56 @@ public class AiWorkflowService {
private final TempFileManager tempFileManager;
private final FileIdStrategy fileIdStrategy;
private final AiEngineEndpointResolver endpointResolver;
private final UserServiceInterface userService;
private final ApplicationProperties applicationProperties;
public AiWorkflowService(
CustomPDFDocumentFactory pdfDocumentFactory,
AiEngineClient aiEngineClient,
PdfContentExtractor pdfContentExtractor,
ObjectMapper objectMapper,
InternalApiClient internalApiClient,
FileStorage fileStorage,
ToolMetadataService toolMetadataService,
TempFileManager tempFileManager,
FileIdStrategy fileIdStrategy,
AiEngineEndpointResolver endpointResolver,
@Autowired(required = false) UserServiceInterface userService,
ApplicationProperties applicationProperties) {
this.pdfDocumentFactory = pdfDocumentFactory;
this.aiEngineClient = aiEngineClient;
this.pdfContentExtractor = pdfContentExtractor;
this.objectMapper = objectMapper;
this.internalApiClient = internalApiClient;
this.fileStorage = fileStorage;
this.toolMetadataService = toolMetadataService;
this.tempFileManager = tempFileManager;
this.fileIdStrategy = fileIdStrategy;
this.endpointResolver = endpointResolver;
this.userService = userService;
this.applicationProperties = applicationProperties;
}
/**
* How long an AI-workflow-ingested personal doc lives on the engine before the reaper deletes
* it. Mirrors the configured web JWT lifetime, so a stale cookie can never see data the user
* has lost their session to. Org-shared content (when we add it) bypasses this and sends a null
* {@code expiresAt} so it's persistent.
*/
private Duration personalDocTtl() {
int minutes = DesktopClientUtils.getWebTokenExpiryMinutes(applicationProperties);
return Duration.ofMinutes(minutes);
}
/**
* Resolve the currently-authenticated user's id for X-User-Id propagation to the AI engine.
* Returns null when security is disabled (no UserServiceInterface bean) or no one is logged in.
* The engine rejects per-user routes (ingest, search) when this is null; non-tenant routes
* (health, orchestrate without RAG) still work.
*/
private String currentUserId() {
return userService != null ? userService.getCurrentUsername() : null;
}
@FunctionalInterface
public interface ProgressListener {
@@ -298,10 +352,21 @@ public class AiWorkflowService {
}
}
}
// Personal-doc semantics for AI workflows today: caller owns the doc and is its only
// grantee, with a session-bounded expiry so the reaper cleans up if logout misses.
// When org / shared-doc ingestion lands, the caller chooses owner, grantees, and
// expiry (null = persistent) explicitly.
String callerId = currentUserId();
AiDocumentIngestRequest ingestRequest =
new AiDocumentIngestRequest(file.getId(), file.getName(), pages);
new AiDocumentIngestRequest(
file.getId(),
file.getName(),
pages,
callerId,
callerId == null ? List.of() : List.of(callerId),
Instant.now().plus(personalDocTtl()));
String body = objectMapper.writeValueAsString(ingestRequest);
aiEngineClient.postLongRunning(DOCUMENTS_ENDPOINT, body);
aiEngineClient.postLongRunning(DOCUMENTS_ENDPOINT, body, callerId);
log.debug(
"Ingested document: id={}, name={}, pages={}",
file.getId(),
@@ -699,6 +764,7 @@ public class AiWorkflowService {
aiEngineClient.streamPost(
"/api/v1/orchestrator",
requestBody,
currentUserId(),
line -> handleStreamLine(line, listener, resultHolder, errorHolder));
if (errorHolder[0] != null) {
@@ -8,13 +8,14 @@ import java.util.List;
import java.util.UUID;
import org.apache.pdfbox.pdmodel.PDDocument;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import org.springframework.web.multipart.MultipartFile;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import stirling.software.common.service.CustomPDFDocumentFactory;
import stirling.software.common.service.UserServiceInterface;
import stirling.software.proprietary.model.api.ai.Evidence;
import stirling.software.proprietary.model.api.ai.Folio;
import stirling.software.proprietary.model.api.ai.FolioManifest;
@@ -40,7 +41,6 @@ import tools.jackson.databind.ObjectMapper;
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class MathAuditorOrchestrator {
private static final String EXAMINE_PATH = "/api/v1/ai/math-auditor-agent/examine";
@@ -50,6 +50,24 @@ public class MathAuditorOrchestrator {
private final CustomPDFDocumentFactory pdfDocumentFactory;
private final PdfContentExtractor pdfContentExtractor;
private final ObjectMapper objectMapper;
private final UserServiceInterface userService;
public MathAuditorOrchestrator(
AiEngineClient aiEngineClient,
CustomPDFDocumentFactory pdfDocumentFactory,
PdfContentExtractor pdfContentExtractor,
ObjectMapper objectMapper,
@Autowired(required = false) UserServiceInterface userService) {
this.aiEngineClient = aiEngineClient;
this.pdfDocumentFactory = pdfDocumentFactory;
this.pdfContentExtractor = pdfContentExtractor;
this.objectMapper = objectMapper;
this.userService = userService;
}
private String currentUserId() {
return userService != null ? userService.getCurrentUsername() : null;
}
/**
* Run a full math audit against the supplied PDF file.
@@ -110,7 +128,7 @@ public class MathAuditorOrchestrator {
EXAMINE_PATH,
manifest.sessionId(),
manifest.round());
String responseBody = aiEngineClient.post(EXAMINE_PATH, requestBody);
String responseBody = aiEngineClient.post(EXAMINE_PATH, requestBody, currentUserId());
return objectMapper.readValue(responseBody, Requisition.class);
}
@@ -123,7 +141,7 @@ public class MathAuditorOrchestrator {
evidence.sessionId(),
evidence.round(),
evidence.finalRound());
String responseBody = aiEngineClient.post(path, requestBody);
String responseBody = aiEngineClient.post(path, requestBody, currentUserId());
return objectMapper.readValue(responseBody, Verdict.class);
}
@@ -10,18 +10,19 @@ import java.util.UUID;
import org.apache.commons.io.FilenameUtils;
import org.apache.pdfbox.pdmodel.PDDocument;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Service;
import org.springframework.web.multipart.MultipartFile;
import org.springframework.web.server.ResponseStatusException;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import stirling.software.common.model.api.comments.AnnotationLocation;
import stirling.software.common.model.api.comments.StickyNoteSpec;
import stirling.software.common.service.CustomPDFDocumentFactory;
import stirling.software.common.service.PdfAnnotationService;
import stirling.software.common.service.UserServiceInterface;
import stirling.software.proprietary.model.api.ai.comments.PdfCommentEngineRequest;
import stirling.software.proprietary.model.api.ai.comments.PdfCommentEngineResponse;
import stirling.software.proprietary.model.api.ai.comments.PdfCommentInstruction;
@@ -50,7 +51,6 @@ import tools.jackson.databind.ObjectMapper;
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class PdfCommentAgentOrchestrator {
private static final String GENERATE_PATH = "/api/v1/ai/pdf-comment-agent/generate";
@@ -80,6 +80,26 @@ public class PdfCommentAgentOrchestrator {
private final CustomPDFDocumentFactory pdfDocumentFactory;
private final ObjectMapper objectMapper;
private final PdfAnnotationService pdfAnnotationService;
private final UserServiceInterface userService;
public PdfCommentAgentOrchestrator(
AiEngineClient aiEngineClient,
PdfTextChunkExtractor pdfTextChunkExtractor,
CustomPDFDocumentFactory pdfDocumentFactory,
ObjectMapper objectMapper,
PdfAnnotationService pdfAnnotationService,
@Autowired(required = false) UserServiceInterface userService) {
this.aiEngineClient = aiEngineClient;
this.pdfTextChunkExtractor = pdfTextChunkExtractor;
this.pdfDocumentFactory = pdfDocumentFactory;
this.objectMapper = objectMapper;
this.pdfAnnotationService = pdfAnnotationService;
this.userService = userService;
}
private String currentUserId() {
return userService != null ? userService.getCurrentUsername() : null;
}
/**
* Run the full PDF comment generation flow.
@@ -164,7 +184,7 @@ public class PdfCommentAgentOrchestrator {
PdfCommentEngineRequest engineRequest =
new PdfCommentEngineRequest(sessionId, prompt, chunks);
String requestBody = objectMapper.writeValueAsString(engineRequest);
String responseBody = aiEngineClient.post(GENERATE_PATH, requestBody);
String responseBody = aiEngineClient.post(GENERATE_PATH, requestBody, currentUserId());
PdfCommentEngineResponse engineResponse =
objectMapper.readValue(responseBody, PdfCommentEngineResponse.class);
@@ -79,7 +79,8 @@ class AuthControllerLoginTest {
totpService,
refreshRateLimitService,
securityProperties,
applicationProperties);
applicationProperties,
new stirling.software.proprietary.service.AiUserDataService(null));
mockMvc = MockMvcBuilders.standaloneSetup(controller).build();
}
@@ -251,6 +251,35 @@ class JwtServiceTest {
assertNull(jwtService.extractToken(request));
}
@Test
void testExtractUsernameFromRequestAllowExpiredReturnsUsername() throws Exception {
String username = "alice";
when(keystoreService.getActiveKey()).thenReturn(testVerificationKey);
when(keystoreService.getKeyPair("test-key-id")).thenReturn(Optional.of(testKeyPair));
String token = jwtService.generateToken(username, Collections.emptyMap());
when(request.getHeader("Authorization")).thenReturn("Bearer " + token);
assertEquals(username, jwtService.extractUsernameFromRequestAllowExpired(request));
}
@Test
void testExtractUsernameFromRequestAllowExpiredReturnsNullWhenNoToken() {
when(request.getHeader("Authorization")).thenReturn(null);
assertNull(jwtService.extractUsernameFromRequestAllowExpired(request));
}
@Test
void testExtractUsernameFromRequestAllowExpiredReturnsNullOnGarbageToken() {
// The logout path depends on this helper returning null - never throwing - so a
// malformed JWT never blocks a user from logging out. Any parse/validation problem
// collapses to "no resolvable user".
when(request.getHeader("Authorization")).thenReturn("Bearer not-a-real-jwt");
assertNull(jwtService.extractUsernameFromRequestAllowExpired(request));
}
@Test
void testGenerateTokenWithKeyId() throws Exception {
String username = "testuser";
@@ -46,7 +46,7 @@ class AiEngineClientTest {
when(httpClient.send(any(), any(HttpResponse.BodyHandler.class))).thenThrow(cause);
ResponseStatusException ex =
assertThrows(ResponseStatusException.class, () -> client.post("/x", "{}"));
assertThrows(ResponseStatusException.class, () -> client.post("/x", "{}", null));
assertEquals(HttpStatus.SERVICE_UNAVAILABLE, ex.getStatusCode());
assertSame(cause, ex.getCause(), "Original cause should be preserved for diagnostics");
@@ -58,7 +58,7 @@ class AiEngineClientTest {
when(httpClient.send(any(), any(HttpResponse.BodyHandler.class))).thenThrow(cause);
ResponseStatusException ex =
assertThrows(ResponseStatusException.class, () -> client.post("/x", "{}"));
assertThrows(ResponseStatusException.class, () -> client.post("/x", "{}", null));
assertEquals(HttpStatus.GATEWAY_TIMEOUT, ex.getStatusCode());
assertSame(cause, ex.getCause());
@@ -70,7 +70,7 @@ class AiEngineClientTest {
when(httpClient.send(any(), any(HttpResponse.BodyHandler.class))).thenThrow(cause);
ResponseStatusException ex =
assertThrows(ResponseStatusException.class, () -> client.get("/x"));
assertThrows(ResponseStatusException.class, () -> client.get("/x", null));
assertEquals(HttpStatus.SERVICE_UNAVAILABLE, ex.getStatusCode());
}
@@ -80,7 +80,7 @@ class AiEngineClientTest {
applicationProperties.getAiEngine().setEnabled(false);
ResponseStatusException ex =
assertThrows(ResponseStatusException.class, () -> client.post("/x", "{}"));
assertThrows(ResponseStatusException.class, () -> client.post("/x", "{}", null));
assertEquals(HttpStatus.SERVICE_UNAVAILABLE, ex.getStatusCode());
}
@@ -0,0 +1,76 @@
package stirling.software.proprietary.service;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.doThrow;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
import java.io.IOException;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.http.HttpStatus;
import org.springframework.web.server.ResponseStatusException;
/**
* Tests the wiring of {@link AiUserDataService}: the path/userId combination forwarded to the
* engine, plus the swallowing-not-throwing behaviour for engine failures. Doesn't try to assert
* {@code @Async} dispatch - that's Spring infrastructure and only fires through the proxy, which a
* unit-level test bypasses by design.
*/
@ExtendWith(MockitoExtension.class)
class AiUserDataServiceTest {
private static final String PURGE_PATH = "/api/v1/documents/by-owner";
@Mock private AiEngineClient aiEngineClient;
private AiUserDataService service;
@BeforeEach
void setUp() {
service = new AiUserDataService(aiEngineClient);
}
@Test
void purgesUnderTheGivenUserIdOnTheByOwnerEndpoint() throws IOException {
service.purgeUserDocuments("alice");
verify(aiEngineClient, times(1)).delete(eq(PURGE_PATH), eq("alice"));
}
@Test
void noOpWhenUserIdIsNull() throws IOException {
service.purgeUserDocuments(null);
verify(aiEngineClient, never()).delete(eq(PURGE_PATH), eq(null));
}
@Test
void noOpWhenUserIdIsBlank() throws IOException {
service.purgeUserDocuments(" ");
verify(aiEngineClient, never()).delete(eq(PURGE_PATH), eq(" "));
}
@Test
void swallowsIoExceptionFromEngine() throws IOException {
doThrow(new IOException("connection refused"))
.when(aiEngineClient)
.delete(eq(PURGE_PATH), eq("alice"));
// Must not throw - the logout path depends on this swallowing failures so the user
// can still log out when the engine is unreachable.
service.purgeUserDocuments("alice");
verify(aiEngineClient, times(1)).delete(eq(PURGE_PATH), eq("alice"));
}
@Test
void swallowsResponseStatusExceptionFromEngine() throws IOException {
doThrow(new ResponseStatusException(HttpStatus.BAD_GATEWAY, "engine returned 502"))
.when(aiEngineClient)
.delete(eq(PURGE_PATH), eq("alice"));
service.purgeUserDocuments("alice");
verify(aiEngineClient, times(1)).delete(eq(PURGE_PATH), eq("alice"));
}
}
@@ -8,6 +8,7 @@ import static org.mockito.ArgumentMatchers.anyBoolean;
import static org.mockito.ArgumentMatchers.anyInt;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.ArgumentMatchers.nullable;
import static org.mockito.Mockito.doAnswer;
import static org.mockito.Mockito.lenient;
import static org.mockito.Mockito.never;
@@ -117,7 +118,9 @@ class AiWorkflowServiceTest {
toolMetadataService,
tempFileManager,
fileIdStrategy,
endpointResolver);
endpointResolver,
null,
new ApplicationProperties());
when(endpointResolver.getEnabledEndpointUrls()).thenReturn(List.of());
}
@@ -479,18 +482,20 @@ class AiWorkflowServiceTest {
{"outcome":"answer","answer":"done","evidence":[]}
""";
}
Consumer<String> consumer = inv.getArgument(2);
Consumer<String> consumer = inv.getArgument(3);
consumer.accept(wrapAsResultEvent(responseJson));
return null;
})
.when(aiEngineClient)
.streamPost(eq("/api/v1/orchestrator"), anyString(), any());
.streamPost(eq("/api/v1/orchestrator"), anyString(), nullable(String.class), any());
AiWorkflowResponse result = service.orchestrate(requestFor(input, "summarise this"));
assertEquals(AiWorkflowOutcome.ANSWER, result.getOutcome());
verify(aiEngineClient, times(1)).postLongRunning(eq("/api/v1/documents"), anyString());
verify(aiEngineClient, times(2)).streamPost(eq("/api/v1/orchestrator"), anyString(), any());
verify(aiEngineClient, times(1))
.postLongRunning(eq("/api/v1/documents"), anyString(), nullable(String.class));
verify(aiEngineClient, times(2))
.streamPost(eq("/api/v1/orchestrator"), anyString(), nullable(String.class), any());
}
// --- helpers ---
@@ -498,12 +503,12 @@ class AiWorkflowServiceTest {
private void stubOrchestrator(String responseJson) throws IOException {
doAnswer(
inv -> {
Consumer<String> consumer = inv.getArgument(2);
Consumer<String> consumer = inv.getArgument(3);
consumer.accept(wrapAsResultEvent(responseJson));
return null;
})
.when(aiEngineClient)
.streamPost(eq("/api/v1/orchestrator"), anyString(), any());
.streamPost(eq("/api/v1/orchestrator"), anyString(), nullable(String.class), any());
}
/**
@@ -6,6 +6,7 @@ import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.ArgumentMatchers.nullable;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
@@ -72,7 +73,8 @@ class PdfCommentAgentOrchestratorTest {
pdfTextChunkExtractor,
pdfDocumentFactory,
objectMapper,
pdfAnnotationService);
pdfAnnotationService,
null);
}
@Test
@@ -96,7 +98,7 @@ class PdfCommentAgentOrchestratorTest {
new PdfCommentInstruction(
"p1-c0", "Comment on page 1", null, null)),
"reviewed");
when(aiEngineClient.post(anyString(), anyString()))
when(aiEngineClient.post(anyString(), anyString(), nullable(String.class)))
.thenReturn(objectMapper.writeValueAsString(engineResponse));
AnnotatedPdf result = orchestrator.applyComments(input, "please comment");
@@ -130,7 +132,7 @@ class PdfCommentAgentOrchestratorTest {
new PdfCommentInstruction("p0-c0", "Valid", null, null),
new PdfCommentInstruction("p999-c999", "Bogus", null, null)),
"mixed");
when(aiEngineClient.post(anyString(), anyString()))
when(aiEngineClient.post(anyString(), anyString(), nullable(String.class)))
.thenReturn(objectMapper.writeValueAsString(engineResponse));
AnnotatedPdf result = orchestrator.applyComments(input, "test");
@@ -156,7 +158,7 @@ class PdfCommentAgentOrchestratorTest {
PdfCommentEngineResponse engineResponse =
new PdfCommentEngineResponse("s", List.of(), "no comments worth making");
when(aiEngineClient.post(anyString(), anyString()))
when(aiEngineClient.post(anyString(), anyString(), nullable(String.class)))
.thenReturn(objectMapper.writeValueAsString(engineResponse));
AnnotatedPdf result = orchestrator.applyComments(input, "test");
@@ -184,7 +186,7 @@ class PdfCommentAgentOrchestratorTest {
ResponseStatusException.class,
() -> orchestrator.applyComments(input, "whatever"));
assertEquals(400, ex.getStatusCode().value());
verify(aiEngineClient, never()).post(anyString(), anyString());
verify(aiEngineClient, never()).post(anyString(), anyString(), nullable(String.class));
}
@Test
@@ -197,7 +199,7 @@ class PdfCommentAgentOrchestratorTest {
ResponseStatusException.class,
() -> orchestrator.applyComments(input, tooLong));
assertEquals(400, ex.getStatusCode().value());
verify(aiEngineClient, never()).post(anyString(), anyString());
verify(aiEngineClient, never()).post(anyString(), anyString(), nullable(String.class));
}
@Test
@@ -209,7 +211,7 @@ class PdfCommentAgentOrchestratorTest {
ResponseStatusException.class,
() -> orchestrator.applyComments(input, " "));
assertEquals(400, ex.getStatusCode().value());
verify(aiEngineClient, never()).post(anyString(), anyString());
verify(aiEngineClient, never()).post(anyString(), anyString(), nullable(String.class));
}
// ---------------------------------------------------------------------