Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c99674f430 |
@@ -699,6 +699,7 @@ public class ApplicationProperties {
|
||||
private boolean enabled = true;
|
||||
private int level = 2; // 0=OFF, 1=BASIC, 2=STANDARD, 3=VERBOSE
|
||||
private int retentionDays = 90;
|
||||
private boolean logIpAddresses = false; // Privacy: IP logging disabled by default
|
||||
}
|
||||
|
||||
@Data
|
||||
|
||||
+68
-47
@@ -1,7 +1,7 @@
|
||||
package stirling.software.SPDF.controller.api;
|
||||
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.io.IOException;
|
||||
import java.io.OutputStream;
|
||||
import java.nio.file.Files;
|
||||
import java.util.*;
|
||||
import java.util.zip.ZipEntry;
|
||||
@@ -22,6 +22,7 @@ import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
import org.springframework.web.servlet.mvc.method.annotation.StreamingResponseBody;
|
||||
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
@@ -33,6 +34,7 @@ import stirling.software.SPDF.model.api.SplitPdfBySectionsRequest;
|
||||
import stirling.software.common.service.CustomPDFDocumentFactory;
|
||||
import stirling.software.common.util.ExceptionUtils;
|
||||
import stirling.software.common.util.GeneralUtils;
|
||||
import stirling.software.common.util.PDFService;
|
||||
import stirling.software.common.util.TempFile;
|
||||
import stirling.software.common.util.TempFileManager;
|
||||
import stirling.software.common.util.WebResponseUtils;
|
||||
@@ -45,6 +47,7 @@ public class SplitPdfBySectionsController {
|
||||
|
||||
private final CustomPDFDocumentFactory pdfDocumentFactory;
|
||||
private final TempFileManager tempFileManager;
|
||||
private final PDFService pdfService;
|
||||
|
||||
@PostMapping(value = "/split-pdf-by-sections", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
|
||||
@Operation(
|
||||
@@ -54,8 +57,8 @@ public class SplitPdfBySectionsController {
|
||||
+ " which page to split, and how to split"
|
||||
+ " ( halves, thirds, quarters, etc.), both vertically and horizontally."
|
||||
+ " Input:PDF Output:ZIP-PDF Type:SISO")
|
||||
public ResponseEntity<?> splitPdf(@ModelAttribute SplitPdfBySectionsRequest request)
|
||||
throws Exception {
|
||||
public ResponseEntity<StreamingResponseBody> splitPdf(
|
||||
@ModelAttribute SplitPdfBySectionsRequest request) throws Exception {
|
||||
MultipartFile file = request.getFileInput();
|
||||
String pageNumbers = request.getPageNumbers();
|
||||
SplitTypes splitMode =
|
||||
@@ -63,10 +66,9 @@ public class SplitPdfBySectionsController {
|
||||
.map(SplitTypes::valueOf)
|
||||
.orElse(SplitTypes.SPLIT_ALL);
|
||||
|
||||
try (PDDocument sourceDocument = pdfDocumentFactory.load(file, true)) {
|
||||
int totalPages = sourceDocument.getNumberOfPages();
|
||||
Set<Integer> customPagesToSplit =
|
||||
getCustomPagesToSplit(pageNumbers, splitMode, totalPages);
|
||||
try (PDDocument sourceDocument = pdfDocumentFactory.load(file)) {
|
||||
Set<Integer> pagesToSplit =
|
||||
getPagesToSplit(pageNumbers, splitMode, sourceDocument.getNumberOfPages());
|
||||
|
||||
// Process the PDF based on split parameters
|
||||
int horiz = request.getHorizontalDivisions() + 1;
|
||||
@@ -75,13 +77,14 @@ public class SplitPdfBySectionsController {
|
||||
String filename = GeneralUtils.generateFilename(file.getOriginalFilename(), "_split");
|
||||
|
||||
if (merge) {
|
||||
try (PDDocument mergedDoc =
|
||||
pdfDocumentFactory.createNewDocumentBasedOnOldDocument(
|
||||
sourceDocument);
|
||||
ByteArrayOutputStream baos = new ByteArrayOutputStream()) {
|
||||
TempFile tempFile = new TempFile(tempFileManager, ".pdf");
|
||||
try (PDDocument mergedDoc = pdfDocumentFactory.createNewDocument();
|
||||
OutputStream out = Files.newOutputStream(tempFile.getPath())) {
|
||||
LayerUtility layerUtility = new LayerUtility(mergedDoc);
|
||||
for (int pageIndex = 0; pageIndex < totalPages; pageIndex++) {
|
||||
if (shouldSplitPage(pageIndex, totalPages, splitMode, customPagesToSplit)) {
|
||||
for (int pageIndex = 0;
|
||||
pageIndex < sourceDocument.getNumberOfPages();
|
||||
pageIndex++) {
|
||||
if (pagesToSplit.contains(pageIndex)) {
|
||||
addSplitPageToTarget(
|
||||
sourceDocument,
|
||||
pageIndex,
|
||||
@@ -93,16 +96,18 @@ public class SplitPdfBySectionsController {
|
||||
addPageToTarget(sourceDocument, pageIndex, mergedDoc, layerUtility);
|
||||
}
|
||||
}
|
||||
mergedDoc.save(baos);
|
||||
return WebResponseUtils.baosToWebResponse(baos, filename + ".pdf");
|
||||
mergedDoc.save(out);
|
||||
}
|
||||
return WebResponseUtils.pdfFileToWebResponse(tempFile, filename + ".pdf");
|
||||
} else {
|
||||
TempFile zipTempFile = new TempFile(tempFileManager, ".zip");
|
||||
try (ZipOutputStream zipOut =
|
||||
new ZipOutputStream(Files.newOutputStream(zipTempFile.getPath()))) {
|
||||
for (int pageIndex = 0; pageIndex < totalPages; pageIndex++) {
|
||||
for (int pageIndex = 0;
|
||||
pageIndex < sourceDocument.getNumberOfPages();
|
||||
pageIndex++) {
|
||||
int pageNum = pageIndex + 1;
|
||||
if (shouldSplitPage(pageIndex, totalPages, splitMode, customPagesToSplit)) {
|
||||
if (pagesToSplit.contains(pageIndex)) {
|
||||
for (int i = 0; i < horiz; i++) {
|
||||
for (int j = 0; j < verti; j++) {
|
||||
try (PDDocument subDoc =
|
||||
@@ -240,37 +245,53 @@ public class SplitPdfBySectionsController {
|
||||
}
|
||||
|
||||
// Based on the mode, get the pages that need to be split and return the pages set
|
||||
private Set<Integer> getCustomPagesToSplit(
|
||||
String pageNumbers, SplitTypes splitMode, int totalPages) {
|
||||
if (splitMode != SplitTypes.CUSTOM) {
|
||||
return Collections.emptySet();
|
||||
}
|
||||
if (pageNumbers == null || pageNumbers.isBlank()) {
|
||||
throw ExceptionUtils.createIllegalArgumentException(
|
||||
"error.argumentRequired",
|
||||
"{0} is required for {1} mode",
|
||||
"page numbers",
|
||||
"custom");
|
||||
}
|
||||
String[] pageOrderArr = pageNumbers.split(",");
|
||||
List<Integer> pageListToSplit = GeneralUtils.parsePageList(pageOrderArr, totalPages, false);
|
||||
return new HashSet<>(pageListToSplit);
|
||||
}
|
||||
private Set<Integer> getPagesToSplit(String pageNumbers, SplitTypes splitMode, int totalPages) {
|
||||
Set<Integer> pagesToSplit = new HashSet<>();
|
||||
|
||||
private boolean shouldSplitPage(
|
||||
int pageIndex, int totalPages, SplitTypes splitMode, Set<Integer> customPagesToSplit) {
|
||||
return switch (splitMode) {
|
||||
case CUSTOM -> customPagesToSplit.contains(pageIndex);
|
||||
case SPLIT_ALL -> true;
|
||||
case SPLIT_ALL_EXCEPT_FIRST -> pageIndex > 0;
|
||||
case SPLIT_ALL_EXCEPT_LAST -> pageIndex < totalPages - 1;
|
||||
case SPLIT_ALL_EXCEPT_FIRST_AND_LAST -> pageIndex > 0 && pageIndex < totalPages - 1;
|
||||
default ->
|
||||
switch (splitMode) {
|
||||
case CUSTOM:
|
||||
if (pageNumbers == null || pageNumbers.isBlank()) {
|
||||
throw ExceptionUtils.createIllegalArgumentException(
|
||||
"error.invalidFormat",
|
||||
"Invalid {0} format: {1}",
|
||||
"split mode",
|
||||
splitMode);
|
||||
};
|
||||
"error.argumentRequired",
|
||||
"{0} is required for {1} mode",
|
||||
"page numbers",
|
||||
"custom");
|
||||
}
|
||||
String[] pageOrderArr = pageNumbers.split(",");
|
||||
List<Integer> pageListToSplit =
|
||||
GeneralUtils.parsePageList(pageOrderArr, totalPages, false);
|
||||
pagesToSplit.addAll(pageListToSplit);
|
||||
break;
|
||||
|
||||
case SPLIT_ALL:
|
||||
for (int i = 0; i < totalPages; i++) {
|
||||
pagesToSplit.add(i);
|
||||
}
|
||||
break;
|
||||
|
||||
case SPLIT_ALL_EXCEPT_FIRST:
|
||||
for (int i = 1; i < totalPages; i++) {
|
||||
pagesToSplit.add(i);
|
||||
}
|
||||
break;
|
||||
|
||||
case SPLIT_ALL_EXCEPT_LAST:
|
||||
for (int i = 0; i < totalPages - 1; i++) {
|
||||
pagesToSplit.add(i);
|
||||
}
|
||||
break;
|
||||
|
||||
case SPLIT_ALL_EXCEPT_FIRST_AND_LAST:
|
||||
for (int i = 1; i < totalPages - 1; i++) {
|
||||
pagesToSplit.add(i);
|
||||
}
|
||||
break;
|
||||
|
||||
default:
|
||||
throw ExceptionUtils.createIllegalArgumentException(
|
||||
"error.invalidFormat", "Invalid {0} format: {1}", "split mode", splitMode);
|
||||
}
|
||||
|
||||
return pagesToSplit;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -97,6 +97,7 @@ premium:
|
||||
enabled: true # Enable audit logging
|
||||
level: 2 # Audit logging level: 0=OFF, 1=BASIC, 2=STANDARD, 3=VERBOSE
|
||||
retentionDays: 90 # Number of days to retain audit logs
|
||||
logIpAddresses: false # Log client IP addresses (privacy-sensitive)
|
||||
databaseNotifications:
|
||||
backups:
|
||||
successful: false # set to 'true' to enable email notifications for successful database backups
|
||||
|
||||
+82
-21
@@ -49,13 +49,38 @@ public class AuditAspect {
|
||||
Map<String, Object> auditData =
|
||||
AuditUtils.createBaseAuditData(joinPoint, auditedAnnotation.level());
|
||||
|
||||
// Add HTTP information if we're in a web context
|
||||
ServletRequestAttributes attrs =
|
||||
(ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
|
||||
if (attrs != null) {
|
||||
HttpServletRequest req = attrs.getRequest();
|
||||
String path = req.getRequestURI();
|
||||
String httpMethod = req.getMethod();
|
||||
// Try to find HttpServletRequest from method arguments first (for Security handlers)
|
||||
HttpServletRequest request = null;
|
||||
for (Object arg : joinPoint.getArgs()) {
|
||||
if (arg instanceof HttpServletRequest) {
|
||||
request = (HttpServletRequest) arg;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Fall back to RequestContextHolder if not in method args
|
||||
if (request == null) {
|
||||
ServletRequestAttributes attrs =
|
||||
(ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
|
||||
if (attrs != null) {
|
||||
request = attrs.getRequest();
|
||||
}
|
||||
}
|
||||
|
||||
// Capture principal, origin, and IP early (before any async execution)
|
||||
// Extract IP directly from the request we already have (more reliable than
|
||||
// RequestContextHolder)
|
||||
String capturedPrincipal = auditService.captureCurrentPrincipal();
|
||||
String capturedOrigin = auditService.captureCurrentOrigin();
|
||||
String capturedIp = null;
|
||||
if (request != null && auditConfig.isLogIpAddresses()) {
|
||||
capturedIp = AuditUtils.extractClientIp(request);
|
||||
}
|
||||
|
||||
// Add HTTP information if we have a valid request
|
||||
if (request != null) {
|
||||
String path = request.getRequestURI();
|
||||
String httpMethod = request.getMethod();
|
||||
AuditUtils.addHttpData(auditData, httpMethod, path, auditedAnnotation.level());
|
||||
AuditUtils.addFileData(auditData, joinPoint, auditedAnnotation.level());
|
||||
}
|
||||
@@ -101,20 +126,43 @@ public class AuditAspect {
|
||||
// Re-throw the exception
|
||||
throw ex;
|
||||
} finally {
|
||||
// Add timing information - use isHttpRequest=false to ensure we get timing for non-HTTP
|
||||
// methods
|
||||
HttpServletResponse resp = attrs != null ? attrs.getResponse() : null;
|
||||
boolean isHttpRequest = attrs != null;
|
||||
AuditUtils.addTimingData(
|
||||
auditData, startTime, resp, auditedAnnotation.level(), isHttpRequest);
|
||||
// Find HttpServletResponse from method arguments first
|
||||
HttpServletResponse response = null;
|
||||
for (Object arg : joinPoint.getArgs()) {
|
||||
if (arg instanceof HttpServletResponse) {
|
||||
response = (HttpServletResponse) arg;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Fall back to RequestContextHolder for response (most controllers don't have it in
|
||||
// args)
|
||||
if (response == null) {
|
||||
ServletRequestAttributes attrs =
|
||||
(ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
|
||||
if (attrs != null) {
|
||||
response = attrs.getResponse();
|
||||
}
|
||||
}
|
||||
|
||||
// Add timing directly (like ControllerAuditAspect) when we have a request
|
||||
if (auditedAnnotation.level().includes(AuditLevel.STANDARD)) {
|
||||
auditData.put("latencyMs", System.currentTimeMillis() - startTime);
|
||||
if (response != null) {
|
||||
try {
|
||||
auditData.put("statusCode", response.getStatus());
|
||||
} catch (Exception e) {
|
||||
// Ignore
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Resolve the event type based on annotation and context
|
||||
String httpMethod = null;
|
||||
String path = null;
|
||||
if (attrs != null) {
|
||||
HttpServletRequest req = attrs.getRequest();
|
||||
httpMethod = req.getMethod();
|
||||
path = req.getRequestURI();
|
||||
if (request != null) {
|
||||
httpMethod = request.getMethod();
|
||||
path = request.getRequestURI();
|
||||
}
|
||||
|
||||
AuditEventType eventType =
|
||||
@@ -128,11 +176,24 @@ public class AuditAspect {
|
||||
// Check if we should use string type instead
|
||||
String typeString = auditedAnnotation.typeString();
|
||||
if (eventType == AuditEventType.HTTP_REQUEST && StringUtils.isNotEmpty(typeString)) {
|
||||
// Use the string type (for backward compatibility)
|
||||
auditService.audit(typeString, auditData, auditedAnnotation.level());
|
||||
// Use the string type (for backward compatibility) with captured principal, origin,
|
||||
// and IP
|
||||
auditService.audit(
|
||||
capturedPrincipal,
|
||||
capturedOrigin,
|
||||
capturedIp,
|
||||
typeString,
|
||||
auditData,
|
||||
auditedAnnotation.level());
|
||||
} else {
|
||||
// Use the enum type (preferred)
|
||||
auditService.audit(eventType, auditData, auditedAnnotation.level());
|
||||
// Use the enum type (preferred) with captured principal, origin, and IP
|
||||
auditService.audit(
|
||||
capturedPrincipal,
|
||||
capturedOrigin,
|
||||
capturedIp,
|
||||
eventType,
|
||||
auditData,
|
||||
auditedAnnotation.level());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -48,17 +48,6 @@ public class AuditUtils {
|
||||
ProceedingJoinPoint joinPoint, AuditLevel auditLevel) {
|
||||
Map<String, Object> data = new HashMap<>();
|
||||
|
||||
// Common data for all levels
|
||||
data.put("timestamp", Instant.now().toString());
|
||||
|
||||
// Add principal if available
|
||||
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
|
||||
if (auth != null && auth.getName() != null) {
|
||||
data.put("principal", auth.getName());
|
||||
} else {
|
||||
data.put("principal", "system");
|
||||
}
|
||||
|
||||
// Add class name and method name only at VERBOSE level
|
||||
if (auditLevel.includes(AuditLevel.VERBOSE)) {
|
||||
data.put("className", joinPoint.getTarget().getClass().getName());
|
||||
@@ -102,7 +91,6 @@ public class AuditUtils {
|
||||
|
||||
// STANDARD level HTTP data
|
||||
if (auditLevel.includes(AuditLevel.STANDARD)) {
|
||||
data.put("clientIp", req.getRemoteAddr());
|
||||
data.put(
|
||||
"sessionId",
|
||||
req.getSession(false) != null ? req.getSession(false).getId() : null);
|
||||
@@ -424,4 +412,38 @@ public class AuditUtils {
|
||||
&& !RequestUriUtils.isTrackableResource(
|
||||
request.getContextPath(), request.getRequestURI());
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract client IP address from HTTP request, preferring forwarded headers for use behind
|
||||
* proxies/load balancers. Truncates to 45 characters to fit database column constraints.
|
||||
*
|
||||
* @param request The HTTP request (may be null)
|
||||
* @return The client IP address, or null if not available
|
||||
*/
|
||||
public static String extractClientIp(HttpServletRequest request) {
|
||||
if (request == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Prefer X-Forwarded-For header (used by most proxies/load balancers)
|
||||
String xff = request.getHeader("X-Forwarded-For");
|
||||
if (xff != null && !xff.isBlank()) {
|
||||
// X-Forwarded-For can be a comma-separated list: "client, proxy1, proxy2"
|
||||
// Take the first (client) IP
|
||||
String first = xff.split(",")[0].trim();
|
||||
return first.length() > 45 ? first.substring(0, 45) : first;
|
||||
}
|
||||
|
||||
// Try X-Real-IP header (used by some reverse proxies like nginx)
|
||||
String realIp = request.getHeader("X-Real-IP");
|
||||
if (realIp != null && !realIp.isBlank()) {
|
||||
return realIp.length() > 45 ? realIp.substring(0, 45) : realIp;
|
||||
}
|
||||
|
||||
// Fall back to remote address
|
||||
String remoteAddr = request.getRemoteAddr();
|
||||
return remoteAddr != null && remoteAddr.length() > 45
|
||||
? remoteAddr.substring(0, 45)
|
||||
: remoteAddr;
|
||||
}
|
||||
}
|
||||
|
||||
+25
-4
@@ -25,6 +25,7 @@ import jakarta.servlet.http.HttpServletResponse;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import stirling.software.common.annotations.AutoJobPostMapping;
|
||||
import stirling.software.proprietary.config.AuditConfigurationProperties;
|
||||
import stirling.software.proprietary.service.AuditService;
|
||||
|
||||
@@ -37,7 +38,7 @@ import stirling.software.proprietary.service.AuditService;
|
||||
@Slf4j
|
||||
@RequiredArgsConstructor
|
||||
@org.springframework.core.annotation.Order(
|
||||
10) // Lower precedence (higher number) - executes after AutoJobAspect
|
||||
0) // Highest precedence - runs on request thread before AutoJobAspect
|
||||
public class ControllerAuditAspect {
|
||||
|
||||
private final AuditService auditService;
|
||||
@@ -121,6 +122,16 @@ public class ControllerAuditAspect {
|
||||
HttpServletRequest req = attrs != null ? attrs.getRequest() : null;
|
||||
HttpServletResponse resp = attrs != null ? attrs.getResponse() : null;
|
||||
|
||||
// Capture principal, origin, and IP (runs on request thread before AutoJobAspect)
|
||||
String capturedPrincipal = auditService.captureCurrentPrincipal();
|
||||
String capturedOrigin = auditService.captureCurrentOrigin();
|
||||
// Extract IP directly from the request we already have (more reliable than
|
||||
// RequestContextHolder)
|
||||
String capturedIp = null;
|
||||
if (req != null && auditConfig.isLogIpAddresses()) {
|
||||
capturedIp = AuditUtils.extractClientIp(req);
|
||||
}
|
||||
|
||||
long start = System.currentTimeMillis();
|
||||
|
||||
// Use AuditUtils to create the base audit data
|
||||
@@ -176,13 +187,15 @@ public class ControllerAuditAspect {
|
||||
String typeString = auditedAnnotation.typeString();
|
||||
if (eventType == AuditEventType.HTTP_REQUEST
|
||||
&& StringUtils.isNotEmpty(typeString)) {
|
||||
auditService.audit(typeString, data, level);
|
||||
auditService.audit(
|
||||
capturedPrincipal, capturedOrigin, capturedIp, typeString, data, level);
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
// Use the enum type
|
||||
auditService.audit(eventType, data, level);
|
||||
// Use the enum type with captured principal, origin, and IP
|
||||
auditService.audit(
|
||||
capturedPrincipal, capturedOrigin, capturedIp, eventType, data, level);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
@@ -194,6 +207,14 @@ public class ControllerAuditAspect {
|
||||
RequestMapping cm = method.getDeclaringClass().getAnnotation(RequestMapping.class);
|
||||
if (cm != null && cm.value().length > 0) base = cm.value()[0];
|
||||
String mp = "";
|
||||
|
||||
// First check for AutoJobPostMapping (which is also a POST)
|
||||
AutoJobPostMapping autoJob = method.getAnnotation(AutoJobPostMapping.class);
|
||||
if (autoJob != null && autoJob.value().length > 0) {
|
||||
return base + autoJob.value()[0];
|
||||
}
|
||||
|
||||
// Fall back to standard mappings
|
||||
Annotation ann =
|
||||
switch (httpMethod) {
|
||||
case "GET" -> method.getAnnotation(GetMapping.class);
|
||||
|
||||
+7
-2
@@ -23,6 +23,7 @@ public class AuditConfigurationProperties {
|
||||
private final boolean enabled;
|
||||
private final int level;
|
||||
private final int retentionDays;
|
||||
private final boolean logIpAddresses;
|
||||
|
||||
public AuditConfigurationProperties(ApplicationProperties applicationProperties) {
|
||||
ApplicationProperties.Premium.EnterpriseFeatures.Audit auditConfig =
|
||||
@@ -37,11 +38,15 @@ public class AuditConfigurationProperties {
|
||||
// Retention days (0 means infinite)
|
||||
this.retentionDays = auditConfig.getRetentionDays();
|
||||
|
||||
// IP address logging (default false for privacy)
|
||||
this.logIpAddresses = auditConfig.isLogIpAddresses();
|
||||
|
||||
log.debug(
|
||||
"Initialized audit configuration: enabled={}, level={}, retentionDays={} (0=infinite)",
|
||||
"Initialized audit configuration: enabled={}, level={}, retentionDays={} (0=infinite), logIpAddresses={}",
|
||||
this.enabled,
|
||||
this.level,
|
||||
this.retentionDays);
|
||||
this.retentionDays,
|
||||
this.logIpAddresses);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
+64
-1
@@ -9,6 +9,8 @@ import org.springframework.boot.actuate.audit.AuditEvent;
|
||||
import org.springframework.boot.actuate.audit.AuditEventRepository;
|
||||
import org.springframework.context.annotation.Primary;
|
||||
import org.springframework.scheduling.annotation.Async;
|
||||
import org.springframework.security.core.Authentication;
|
||||
import org.springframework.security.core.context.SecurityContextHolder;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.util.CollectionUtils;
|
||||
|
||||
@@ -49,6 +51,23 @@ public class CustomAuditEventRepository implements AuditEventRepository {
|
||||
if (clean.isEmpty() || (clean.size() == 1 && clean.containsKey("details"))) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Extract origin and IP from the data map (set by AuditService before async call)
|
||||
String origin = "SYSTEM"; // default
|
||||
String ipAddress = null;
|
||||
|
||||
if (clean.containsKey("__origin") || clean.containsKey("__ipAddress")) {
|
||||
clean = new java.util.HashMap<>(clean);
|
||||
|
||||
if (clean.containsKey("__origin")) {
|
||||
origin = String.valueOf(clean.remove("__origin"));
|
||||
}
|
||||
|
||||
if (clean.containsKey("__ipAddress")) {
|
||||
ipAddress = String.valueOf(clean.remove("__ipAddress"));
|
||||
}
|
||||
}
|
||||
|
||||
String rid = MDC.get("requestId");
|
||||
|
||||
if (rid != null) {
|
||||
@@ -59,16 +78,60 @@ public class CustomAuditEventRepository implements AuditEventRepository {
|
||||
String auditEventData = mapper.writeValueAsString(clean);
|
||||
log.debug("AuditEvent data (JSON): {}", auditEventData);
|
||||
|
||||
String principalName = extractPrincipalName(ev.getPrincipal());
|
||||
|
||||
if (principalName.length() > 255) {
|
||||
log.warn(
|
||||
"Principal length {} exceeds 255 characters, truncating: {}",
|
||||
principalName.length(),
|
||||
principalName.substring(0, Math.min(50, principalName.length())) + "...");
|
||||
principalName = principalName.substring(0, 255);
|
||||
}
|
||||
if (ev.getType().length() > 255) {
|
||||
log.warn(
|
||||
"Type length {} exceeds 255 characters: {}",
|
||||
ev.getType().length(),
|
||||
ev.getType());
|
||||
}
|
||||
|
||||
PersistentAuditEvent ent =
|
||||
PersistentAuditEvent.builder()
|
||||
.principal(ev.getPrincipal())
|
||||
.principal(principalName)
|
||||
.type(ev.getType())
|
||||
.data(auditEventData)
|
||||
.timestamp(ev.getTimestamp())
|
||||
.ipAddress(ipAddress)
|
||||
.origin(origin)
|
||||
.build();
|
||||
repo.save(ent);
|
||||
} catch (Exception e) {
|
||||
e.printStackTrace(); // fail-open
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts a meaningful principal name from the audit event principal. Uses lightweight
|
||||
* approach to avoid performance impact on high-volume audit events.
|
||||
*/
|
||||
private String extractPrincipalName(String principal) {
|
||||
if (principal == null || principal.isEmpty()) {
|
||||
return "anonymous";
|
||||
}
|
||||
|
||||
// Quick check for JWT tokens (they start with "eyJ")
|
||||
if (principal.startsWith("eyJ")) {
|
||||
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
|
||||
if (auth != null && auth.isAuthenticated()) {
|
||||
String name = auth.getName();
|
||||
if (name != null && !name.startsWith("eyJ")) {
|
||||
return name.length() > 255 ? name.substring(0, 255) : name;
|
||||
}
|
||||
}
|
||||
|
||||
// Unverified/failed JWT; don't trust token content
|
||||
return "authentication-failure";
|
||||
}
|
||||
|
||||
return principal.length() > 255 ? principal.substring(0, 255) : principal;
|
||||
}
|
||||
}
|
||||
|
||||
+9
-1
@@ -13,6 +13,7 @@ import lombok.*;
|
||||
@jakarta.persistence.Index(name = "idx_audit_timestamp", columnList = "timestamp"),
|
||||
@jakarta.persistence.Index(name = "idx_audit_principal", columnList = "principal"),
|
||||
@jakarta.persistence.Index(name = "idx_audit_type", columnList = "type"),
|
||||
@jakarta.persistence.Index(name = "idx_audit_origin", columnList = "origin"),
|
||||
@jakarta.persistence.Index(
|
||||
name = "idx_audit_principal_type",
|
||||
columnList = "principal,type"),
|
||||
@@ -33,7 +34,14 @@ public class PersistentAuditEvent {
|
||||
private String principal;
|
||||
private String type;
|
||||
|
||||
@Lob private String data; // JSON blob
|
||||
@Column(columnDefinition = "TEXT")
|
||||
private String data; // JSON blob
|
||||
|
||||
private Instant timestamp;
|
||||
|
||||
@Column(length = 45) // Max length for IPv6 (xxx:xxx:xxx:xxx:xxx:xxx:xxx:xxx)
|
||||
private String ipAddress;
|
||||
|
||||
@Column(length = 10) // "WEB", "API", or "SYSTEM"
|
||||
private String origin;
|
||||
}
|
||||
|
||||
+110
-2
@@ -14,6 +14,7 @@ import lombok.extern.slf4j.Slf4j;
|
||||
import stirling.software.proprietary.audit.AuditEventType;
|
||||
import stirling.software.proprietary.audit.AuditLevel;
|
||||
import stirling.software.proprietary.config.AuditConfigurationProperties;
|
||||
import stirling.software.proprietary.security.model.ApiKeyAuthenticationToken;
|
||||
|
||||
/**
|
||||
* Service for creating manual audit events throughout the application. This provides easy access to
|
||||
@@ -53,7 +54,12 @@ public class AuditService {
|
||||
}
|
||||
|
||||
String principal = getCurrentUsername();
|
||||
repository.add(new AuditEvent(principal, type.name(), data));
|
||||
|
||||
// Add origin to the data map (captured here before async execution)
|
||||
Map<String, Object> enrichedData = new java.util.HashMap<>(data);
|
||||
enrichedData.put("__origin", determineOrigin());
|
||||
|
||||
repository.add(new AuditEvent(principal, type.name(), enrichedData));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -115,7 +121,12 @@ public class AuditService {
|
||||
}
|
||||
|
||||
String principal = getCurrentUsername();
|
||||
repository.add(new AuditEvent(principal, type, data));
|
||||
|
||||
// Add origin to the data map (captured here before async execution)
|
||||
Map<String, Object> enrichedData = new java.util.HashMap<>(data);
|
||||
enrichedData.put("__origin", determineOrigin());
|
||||
|
||||
repository.add(new AuditEvent(principal, type, enrichedData));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -161,9 +172,106 @@ public class AuditService {
|
||||
audit(principal, type, data, AuditLevel.STANDARD);
|
||||
}
|
||||
|
||||
/**
|
||||
* Record an audit event with pre-captured principal, origin, and IP (for use by audit aspects).
|
||||
*/
|
||||
public void audit(
|
||||
String principal,
|
||||
String origin,
|
||||
String ipAddress,
|
||||
AuditEventType type,
|
||||
Map<String, Object> data,
|
||||
AuditLevel level) {
|
||||
if (!auditConfig.isEnabled()
|
||||
|| !auditConfig.getAuditLevel().includes(level)
|
||||
|| !runningEE) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Add origin and IP to the data map (already captured before async)
|
||||
Map<String, Object> enrichedData = new java.util.HashMap<>(data);
|
||||
enrichedData.put("__origin", origin);
|
||||
if (ipAddress != null) {
|
||||
enrichedData.put("__ipAddress", ipAddress);
|
||||
}
|
||||
|
||||
repository.add(new AuditEvent(principal, type.name(), enrichedData));
|
||||
}
|
||||
|
||||
/**
|
||||
* Record an audit event with pre-captured principal, origin, and IP using string type (for
|
||||
* backward compatibility).
|
||||
*/
|
||||
public void audit(
|
||||
String principal,
|
||||
String origin,
|
||||
String ipAddress,
|
||||
String type,
|
||||
Map<String, Object> data,
|
||||
AuditLevel level) {
|
||||
if (!auditConfig.isEnabled()
|
||||
|| !auditConfig.getAuditLevel().includes(level)
|
||||
|| !runningEE) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Add origin and IP to the data map (already captured before async)
|
||||
Map<String, Object> enrichedData = new java.util.HashMap<>(data);
|
||||
enrichedData.put("__origin", origin);
|
||||
if (ipAddress != null) {
|
||||
enrichedData.put("__ipAddress", ipAddress);
|
||||
}
|
||||
|
||||
repository.add(new AuditEvent(principal, type, enrichedData));
|
||||
}
|
||||
|
||||
/** Get the current authenticated username or "system" if none */
|
||||
private String getCurrentUsername() {
|
||||
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
|
||||
return (auth != null && auth.getName() != null) ? auth.getName() : "system";
|
||||
}
|
||||
|
||||
/**
|
||||
* Captures the current principal for later use (avoids SecurityContext issues in async/thread
|
||||
* changes). Public so audit aspects can capture early before thread context changes.
|
||||
*/
|
||||
public String captureCurrentPrincipal() {
|
||||
return getCurrentUsername();
|
||||
}
|
||||
|
||||
/**
|
||||
* Captures the current origin for later use (avoids SecurityContext issues in async/thread
|
||||
* changes). Public so audit aspects can capture early before thread context changes.
|
||||
*/
|
||||
public String captureCurrentOrigin() {
|
||||
return determineOrigin();
|
||||
}
|
||||
|
||||
/**
|
||||
* Determines the origin of the request: API (X-API-KEY), WEB (JWT), or SYSTEM (no auth).
|
||||
* IMPORTANT: This must be called in the request thread before async execution.
|
||||
*
|
||||
* @return "API", "WEB", or "SYSTEM"
|
||||
*/
|
||||
private String determineOrigin() {
|
||||
try {
|
||||
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
|
||||
|
||||
// Check if authenticated via API key
|
||||
if (auth instanceof ApiKeyAuthenticationToken) {
|
||||
return "API";
|
||||
}
|
||||
|
||||
// Check if authenticated via JWT (web user)
|
||||
if (auth != null && auth.isAuthenticated() && !"anonymousUser".equals(auth.getName())) {
|
||||
return "WEB";
|
||||
}
|
||||
|
||||
// System or unauthenticated
|
||||
return "SYSTEM";
|
||||
} catch (Exception e) {
|
||||
log.debug("Could not determine origin for audit event", e);
|
||||
return "SYSTEM";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,7 +15,6 @@ import re
|
||||
from PIL import Image, ImageDraw
|
||||
|
||||
API_HEADERS = {"X-API-KEY": "123456789"}
|
||||
TIMEOUT = 80 # seconds
|
||||
|
||||
#########
|
||||
# GIVEN #
|
||||
@@ -299,11 +298,7 @@ def save_generated_pdf(context, filename):
|
||||
def step_send_get_request(context, endpoint):
|
||||
base_url = "http://localhost:8080"
|
||||
full_url = f"{base_url}{endpoint}"
|
||||
response = requests.get(
|
||||
full_url,
|
||||
headers=API_HEADERS,
|
||||
timeout=getattr(context, "request_timeout", TIMEOUT),
|
||||
)
|
||||
response = requests.get(full_url, headers=API_HEADERS)
|
||||
context.response = response
|
||||
|
||||
|
||||
@@ -312,12 +307,7 @@ def step_send_get_request_with_params(context, endpoint):
|
||||
base_url = "http://localhost:8080"
|
||||
params = {row["parameter"]: row["value"] for row in context.table}
|
||||
full_url = f"{base_url}{endpoint}"
|
||||
response = requests.get(
|
||||
full_url,
|
||||
params=params,
|
||||
headers=API_HEADERS,
|
||||
timeout=getattr(context, "request_timeout", TIMEOUT),
|
||||
)
|
||||
response = requests.get(full_url, params=params, headers=API_HEADERS)
|
||||
context.response = response
|
||||
|
||||
|
||||
@@ -347,12 +337,7 @@ def step_send_api_request(context, endpoint):
|
||||
print(f"form_data {file.name} with {mime_type}")
|
||||
form_data.append((key, (file.name, file, mime_type)))
|
||||
|
||||
response = requests.post(
|
||||
url,
|
||||
files=form_data,
|
||||
headers=API_HEADERS,
|
||||
timeout=getattr(context, "request_timeout", TIMEOUT),
|
||||
)
|
||||
response = requests.post(url, files=form_data, headers=API_HEADERS)
|
||||
context.response = response
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user