Skip to content

Commit 1264f4c

Browse files
authored
Set up document management for Stirling Engine (Stirling-Tools#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.
1 parent 7163386 commit 1264f4c

48 files changed

Lines changed: 2034 additions & 406 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

app/proprietary/src/main/java/stirling/software/proprietary/controller/api/AiEngineController.java

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
import java.util.Map;
66
import java.util.concurrent.Executor;
77

8+
import org.springframework.beans.factory.annotation.Autowired;
89
import org.springframework.beans.factory.annotation.Qualifier;
910
import org.springframework.beans.factory.annotation.Value;
1011
import org.springframework.http.HttpStatus;
@@ -30,6 +31,7 @@
3031
import stirling.software.common.model.job.ResultFile;
3132
import stirling.software.common.service.JobOwnershipService;
3233
import stirling.software.common.service.TaskManager;
34+
import stirling.software.common.service.UserServiceInterface;
3335
import stirling.software.proprietary.model.api.ai.AiWorkflowProgressEvent;
3436
import stirling.software.proprietary.model.api.ai.AiWorkflowRequest;
3537
import stirling.software.proprietary.model.api.ai.AiWorkflowResponse;
@@ -58,6 +60,7 @@ public class AiEngineController {
5860
private final TaskManager taskManager;
5961
private final JobOwnershipService jobOwnershipService;
6062
private final AiEngineEndpointResolver endpointResolver;
63+
private final UserServiceInterface userService;
6164

6265
/**
6366
* SSE emitter timeout. Long enough to accommodate multi-gigabyte PDF workflows (OCR on a
@@ -74,22 +77,28 @@ public AiEngineController(
7477
@Qualifier("aiStreamExecutor") Executor aiStreamExecutor,
7578
TaskManager taskManager,
7679
JobOwnershipService jobOwnershipService,
77-
AiEngineEndpointResolver endpointResolver) {
80+
AiEngineEndpointResolver endpointResolver,
81+
@Autowired(required = false) UserServiceInterface userService) {
7882
this.aiEngineClient = aiEngineClient;
7983
this.aiWorkflowService = aiWorkflowService;
8084
this.objectMapper = objectMapper;
8185
this.aiStreamExecutor = aiStreamExecutor;
8286
this.taskManager = taskManager;
8387
this.jobOwnershipService = jobOwnershipService;
8488
this.endpointResolver = endpointResolver;
89+
this.userService = userService;
90+
}
91+
92+
private String currentUserId() {
93+
return userService != null ? userService.getCurrentUsername() : null;
8594
}
8695

8796
@GetMapping("/health")
8897
@Operation(
8998
summary = "AI engine health check",
9099
description = "Returns the health status of the AI engine including configured models")
91100
public ResponseEntity<String> health() throws IOException {
92-
String response = aiEngineClient.get("/health");
101+
String response = aiEngineClient.get("/health", currentUserId());
93102
return ResponseEntity.ok().contentType(MediaType.APPLICATION_JSON).body(response);
94103
}
95104

@@ -243,7 +252,7 @@ public ResponseEntity<String> pdfEdit(@RequestBody String requestBody) throws IO
243252
HttpStatus.BAD_REQUEST, "Request body must be a JSON object");
244253
}
245254
String forwardedBody = withEnabledEndpoints((ObjectNode) parsed);
246-
String response = aiEngineClient.post("/api/v1/pdf/edit", forwardedBody);
255+
String response = aiEngineClient.post("/api/v1/pdf/edit", forwardedBody, currentUserId());
247256
return ResponseEntity.ok().contentType(MediaType.APPLICATION_JSON).body(response);
248257
}
249258

app/proprietary/src/main/java/stirling/software/proprietary/model/api/ai/AiDocumentIngestRequest.java

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
package stirling.software.proprietary.model.api.ai;
22

3+
import java.time.Instant;
34
import java.util.List;
45

56
import lombok.AllArgsConstructor;
@@ -10,6 +11,12 @@
1011
* Body for {@code POST /api/v1/documents} on the AI engine. Sent by Java when the engine reports
1112
* {@code need_ingest} and the requested document's extracted content must be stored before the
1213
* workflow can continue.
14+
*
15+
* <p>{@code ownerId} is the tenant the doc belongs to (a user for personal uploads, an org for
16+
* shared content). {@code readPrincipals} is the explicit list of principals granted read access.
17+
* {@code expiresAt} is when the engine's reaper should delete this doc; {@code null} means
18+
* "persistent until explicit delete" (used for org-shared content). Java picks the value per doc;
19+
* the engine does not default it.
1320
*/
1421
@Data
1522
@NoArgsConstructor
@@ -21,4 +28,10 @@ public class AiDocumentIngestRequest {
2128
private String source;
2229

2330
private List<AiPageText> pageText;
31+
32+
private String ownerId;
33+
34+
private List<String> readPrincipals;
35+
36+
private Instant expiresAt;
2437
}

app/proprietary/src/main/java/stirling/software/proprietary/security/CustomLogoutSuccessHandler.java

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@
88
import java.util.Locale;
99

1010
import org.springframework.core.io.Resource;
11+
import org.springframework.security.authentication.AuthenticationTrustResolver;
12+
import org.springframework.security.authentication.AuthenticationTrustResolverImpl;
1113
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
1214
import org.springframework.security.core.Authentication;
1315
import org.springframework.security.oauth2.client.authentication.OAuth2AuthenticationToken;
@@ -36,6 +38,7 @@
3638
import stirling.software.proprietary.security.saml2.CertificateUtils;
3739
import stirling.software.proprietary.security.saml2.CustomSaml2AuthenticatedPrincipal;
3840
import stirling.software.proprietary.security.service.JwtServiceInterface;
41+
import stirling.software.proprietary.service.AiUserDataService;
3942

4043
@Slf4j
4144
@RequiredArgsConstructor
@@ -49,12 +52,22 @@ public class CustomLogoutSuccessHandler extends SimpleUrlLogoutSuccessHandler {
4952

5053
private final JwtServiceInterface jwtService;
5154

55+
private final AiUserDataService aiUserDataService;
56+
57+
private static final AuthenticationTrustResolver TRUST_RESOLVER =
58+
new AuthenticationTrustResolverImpl();
59+
5260
@Override
5361
@Audited(type = AuditEventType.USER_LOGOUT, level = AuditLevel.BASIC)
5462
public void onLogoutSuccess(
5563
HttpServletRequest request, HttpServletResponse response, Authentication authentication)
5664
throws IOException {
5765

66+
String username = resolveUsername(request, authentication);
67+
if (username != null) {
68+
aiUserDataService.purgeUserDocuments(username);
69+
}
70+
5871
if (!response.isCommitted()) {
5972
if (authentication != null) {
6073
if (authentication instanceof Saml2Authentication samlAuthentication) {
@@ -88,6 +101,26 @@ public void onLogoutSuccess(
88101
}
89102
}
90103

104+
/**
105+
* Pick the right name to purge under. JWT cookie wins if present and parseable; we fall through
106+
* to whatever Spring handed us only when there's no cookie. Spring's anonymous principal is
107+
* filtered out via {@link AuthenticationTrustResolver} so we don't purge under that
108+
* pseudo-user.
109+
*/
110+
private String resolveUsername(HttpServletRequest request, Authentication authentication) {
111+
if (jwtService != null) {
112+
String fromCookie = jwtService.extractUsernameFromRequestAllowExpired(request);
113+
if (fromCookie != null) {
114+
return fromCookie;
115+
}
116+
}
117+
if (authentication == null || TRUST_RESOLVER.isAnonymous(authentication)) {
118+
return null;
119+
}
120+
String name = authentication.getName();
121+
return (name != null && !name.isBlank()) ? name : null;
122+
}
123+
91124
// Redirect for SAML2 authentication logout
92125
private void getRedirect_saml2(
93126
HttpServletRequest request,

app/proprietary/src/main/java/stirling/software/proprietary/security/configuration/SecurityConfiguration.java

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,7 @@ public class SecurityConfiguration {
9393
licenseSettingsService;
9494
private final ClientRegistrationRepository clientRegistrationRepository;
9595
private final PasswordEncoder passwordEncoder;
96+
private final stirling.software.proprietary.service.AiUserDataService aiUserDataService;
9697

9798
public SecurityConfiguration(
9899
PersistentLoginRepository persistentLoginRepository,
@@ -115,7 +116,8 @@ public SecurityConfiguration(
115116
OpenSaml5AuthenticationRequestResolver saml2AuthenticationRequestResolver,
116117
@Autowired(required = false) ClientRegistrationRepository clientRegistrationRepository,
117118
stirling.software.proprietary.service.UserLicenseSettingsService licenseSettingsService,
118-
PasswordEncoder passwordEncoder) {
119+
PasswordEncoder passwordEncoder,
120+
stirling.software.proprietary.service.AiUserDataService aiUserDataService) {
119121
this.userDetailsService = userDetailsService;
120122
this.userService = userService;
121123
this.loginEnabledValue = loginEnabledValue;
@@ -135,6 +137,7 @@ public SecurityConfiguration(
135137
this.clientRegistrationRepository = clientRegistrationRepository;
136138
this.licenseSettingsService = licenseSettingsService;
137139
this.passwordEncoder = passwordEncoder;
140+
this.aiUserDataService = aiUserDataService;
138141
}
139142

140143
/**
@@ -322,7 +325,10 @@ private SecurityFilterChain configureSecurity(
322325
.matcher("/logout"))
323326
.logoutSuccessHandler(
324327
new CustomLogoutSuccessHandler(
325-
securityProperties, appConfig, jwtService))
328+
securityProperties,
329+
appConfig,
330+
jwtService,
331+
aiUserDataService))
326332
.clearAuthentication(true)
327333
.invalidateHttpSession(true)
328334
.deleteCookies("JSESSIONID", "remember-me", "stirling_jwt"));

app/proprietary/src/main/java/stirling/software/proprietary/security/controller/api/AuthController.java

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@
4444
import stirling.software.proprietary.security.service.TotpService;
4545
import stirling.software.proprietary.security.service.UserService;
4646
import stirling.software.proprietary.security.util.DesktopClientUtils;
47+
import stirling.software.proprietary.service.AiUserDataService;
4748

4849
/** REST API Controller for authentication operations. */
4950
@RestController
@@ -62,6 +63,7 @@ public class AuthController {
6263
private final RefreshRateLimitService refreshRateLimitService;
6364
private final ApplicationProperties.Security securityProperties;
6465
private final ApplicationProperties applicationProperties;
66+
private final AiUserDataService aiUserDataService;
6567

6668
/**
6769
* Login endpoint - replaces Supabase signInWithPassword
@@ -281,11 +283,13 @@ public ResponseEntity<?> getCurrentUser() {
281283
*/
282284
@PreAuthorize("!hasAuthority('ROLE_DEMO_USER')")
283285
@PostMapping("/logout")
284-
public ResponseEntity<?> logout(HttpServletResponse response) {
286+
public ResponseEntity<?> logout(HttpServletRequest request, HttpServletResponse response) {
285287
try {
288+
String username = jwtService.extractUsernameFromRequestAllowExpired(request);
286289
SecurityContextHolder.clearContext();
290+
aiUserDataService.purgeUserDocuments(username);
287291

288-
log.debug("User logged out successfully");
292+
log.debug("User logged out successfully (username={})", username);
289293

290294
return ResponseEntity.ok(Map.of("message", "Logged out successfully"));
291295

app/proprietary/src/main/java/stirling/software/proprietary/security/service/JwtService.java

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -349,6 +349,21 @@ public String extractToken(HttpServletRequest request) {
349349
return null;
350350
}
351351

352+
@Override
353+
public String extractUsernameFromRequestAllowExpired(HttpServletRequest request) {
354+
try {
355+
String token = extractToken(request);
356+
if (token == null || token.isBlank()) {
357+
return null;
358+
}
359+
String username = extractUsernameAllowExpired(token);
360+
return (username != null && !username.isBlank()) ? username : null;
361+
} catch (Exception e) {
362+
log.debug("Could not extract username from request JWT: {}", e.getMessage());
363+
return null;
364+
}
365+
}
366+
352367
@Override
353368
public boolean isJwtEnabled() {
354369
return v2Enabled;

app/proprietary/src/main/java/stirling/software/proprietary/security/service/JwtServiceInterface.java

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,19 @@ public interface JwtServiceInterface {
9393
*/
9494
String extractToken(HttpServletRequest request);
9595

96+
/**
97+
* Read the username off the request's JWT, allowing an expired token. Returns null when no
98+
* token is present, the token can't be parsed, or the resulting username is blank.
99+
*
100+
* <p>Used by flows that need to identify the user without depending on {@code
101+
* SecurityContextHolder} - for example logout, where the security filter chain may have left
102+
* the anonymous principal in place by the time the handler runs.
103+
*
104+
* @param request HTTP servlet request
105+
* @return username from the token, or null when one can't be safely derived
106+
*/
107+
String extractUsernameFromRequestAllowExpired(HttpServletRequest request);
108+
96109
/**
97110
* Check if JWT authentication is enabled
98111
*

0 commit comments

Comments
 (0)