mirror of
https://github.com/arsvendg/Stirling-PDF.git
synced 2026-07-14 10:34:06 +02:00
Audit fixes and improvements (#5835)
This commit is contained in:
@@ -2,12 +2,14 @@ package stirling.software.common.aop;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.time.Duration;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.atomic.AtomicReference;
|
||||
import java.util.function.Supplier;
|
||||
|
||||
import org.aspectj.lang.ProceedingJoinPoint;
|
||||
import org.aspectj.lang.annotation.*;
|
||||
import org.slf4j.MDC;
|
||||
import org.springframework.core.annotation.Order;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
@@ -26,7 +28,7 @@ import stirling.software.common.service.JobExecutorService;
|
||||
@Component
|
||||
@RequiredArgsConstructor
|
||||
@Slf4j
|
||||
@Order(0) // Highest precedence - executes before audit aspects
|
||||
@Order(20) // Lower precedence - executes AFTER audit aspects populate MDC
|
||||
public class AutoJobAspect {
|
||||
|
||||
private static final Duration RETRY_BASE_DELAY = Duration.ofMillis(100);
|
||||
@@ -70,26 +72,29 @@ public class AutoJobAspect {
|
||||
// No retries needed, simple execution
|
||||
return jobExecutorService.runJobGeneric(
|
||||
async,
|
||||
() -> {
|
||||
try {
|
||||
// Note: Progress tracking is handled in TaskManager/JobExecutorService
|
||||
// The trackProgress flag controls whether detailed progress is stored
|
||||
// for REST API queries, not WebSocket notifications
|
||||
return joinPoint.proceed(args);
|
||||
} catch (Throwable ex) {
|
||||
log.error(
|
||||
"AutoJobAspect caught exception during job execution: {}",
|
||||
ex.getMessage(),
|
||||
ex);
|
||||
// Rethrow RuntimeException as-is to preserve exception type
|
||||
if (ex instanceof RuntimeException) {
|
||||
throw (RuntimeException) ex;
|
||||
}
|
||||
// Wrap checked exceptions - GlobalExceptionHandler will unwrap
|
||||
// BaseAppException
|
||||
throw new RuntimeException(ex);
|
||||
}
|
||||
},
|
||||
wrapWithMDC(
|
||||
() -> {
|
||||
try {
|
||||
// Note: Progress tracking is handled in
|
||||
// TaskManager/JobExecutorService
|
||||
// The trackProgress flag controls whether detailed progress is
|
||||
// stored
|
||||
// for REST API queries, not WebSocket notifications
|
||||
return joinPoint.proceed(args);
|
||||
} catch (Throwable ex) {
|
||||
log.error(
|
||||
"AutoJobAspect caught exception during job execution: {}",
|
||||
ex.getMessage(),
|
||||
ex);
|
||||
// Rethrow RuntimeException as-is to preserve exception type
|
||||
if (ex instanceof RuntimeException) {
|
||||
throw (RuntimeException) ex;
|
||||
}
|
||||
// Wrap checked exceptions - GlobalExceptionHandler will unwrap
|
||||
// BaseAppException
|
||||
throw new RuntimeException(ex);
|
||||
}
|
||||
}),
|
||||
timeout,
|
||||
queueable,
|
||||
resourceWeight);
|
||||
@@ -123,114 +128,108 @@ public class AutoJobAspect {
|
||||
|
||||
return jobExecutorService.runJobGeneric(
|
||||
async,
|
||||
() -> {
|
||||
// Use iterative approach instead of recursion to avoid stack overflow
|
||||
Throwable lastException = null;
|
||||
wrapWithMDC(
|
||||
() -> {
|
||||
// Use iterative approach instead of recursion to avoid stack overflow
|
||||
Throwable lastException = null;
|
||||
|
||||
// Attempt counter starts at 1 for first try
|
||||
for (int currentAttempt = 1; currentAttempt <= maxRetries; currentAttempt++) {
|
||||
try {
|
||||
if (trackProgress && async) {
|
||||
// Get jobId for progress tracking in TaskManager
|
||||
// This enables REST API progress queries, not WebSocket
|
||||
if (jobIdRef.get() == null) {
|
||||
jobIdRef.set(getJobIdFromContext());
|
||||
}
|
||||
String jobId = jobIdRef.get();
|
||||
if (jobId != null) {
|
||||
log.debug(
|
||||
"Tracking progress for job {} (attempt {}/{})",
|
||||
jobId,
|
||||
// Attempt counter starts at 1 for first try
|
||||
for (int currentAttempt = 1;
|
||||
currentAttempt <= maxRetries;
|
||||
currentAttempt++) {
|
||||
try {
|
||||
if (trackProgress && async) {
|
||||
// Get jobId for progress tracking in TaskManager
|
||||
// This enables REST API progress queries, not WebSocket
|
||||
if (jobIdRef.get() == null) {
|
||||
jobIdRef.set(getJobIdFromContext());
|
||||
}
|
||||
String jobId = jobIdRef.get();
|
||||
if (jobId != null) {
|
||||
log.debug(
|
||||
"Tracking progress for job {} (attempt {}/{})",
|
||||
jobId,
|
||||
currentAttempt,
|
||||
maxRetries);
|
||||
// Progress is tracked in TaskManager for REST API
|
||||
// access
|
||||
// No WebSocket notifications sent here
|
||||
}
|
||||
}
|
||||
|
||||
// Attempt to execute the operation
|
||||
return joinPoint.proceed(args);
|
||||
|
||||
} catch (Throwable ex) {
|
||||
lastException = ex;
|
||||
log.error(
|
||||
"AutoJobAspect caught exception during job execution (attempt"
|
||||
+ " {}/{}): {}",
|
||||
currentAttempt,
|
||||
maxRetries);
|
||||
// Progress is tracked in TaskManager for REST API access
|
||||
// No WebSocket notifications sent here
|
||||
}
|
||||
}
|
||||
maxRetries,
|
||||
ex.getMessage(),
|
||||
ex);
|
||||
|
||||
// Attempt to execute the operation
|
||||
return joinPoint.proceed(args);
|
||||
// Check if we should retry
|
||||
if (currentAttempt < maxRetries) {
|
||||
log.info(
|
||||
"Retrying operation, attempt {}/{}",
|
||||
currentAttempt + 1,
|
||||
maxRetries);
|
||||
|
||||
} catch (Throwable ex) {
|
||||
lastException = ex;
|
||||
log.error(
|
||||
"AutoJobAspect caught exception during job execution (attempt"
|
||||
+ " {}/{}): {}",
|
||||
currentAttempt,
|
||||
maxRetries,
|
||||
ex.getMessage(),
|
||||
ex);
|
||||
if (trackProgress && async) {
|
||||
String jobId = jobIdRef.get();
|
||||
if (jobId != null) {
|
||||
log.debug(
|
||||
"Recording retry attempt for job {} in TaskManager",
|
||||
jobId);
|
||||
// Retry info is tracked in TaskManager for REST API
|
||||
// access
|
||||
}
|
||||
}
|
||||
|
||||
// Check if we should retry
|
||||
if (currentAttempt < maxRetries) {
|
||||
log.info(
|
||||
"Retrying operation, attempt {}/{}",
|
||||
currentAttempt + 1,
|
||||
maxRetries);
|
||||
// Use sleep for retry delay
|
||||
// For sync jobs, both sleep and async are blocking at this
|
||||
// point
|
||||
// For async jobs, the delay occurs in the executor thread
|
||||
long delayMs = RETRY_BASE_DELAY.toMillis() * currentAttempt;
|
||||
|
||||
if (trackProgress && async) {
|
||||
String jobId = jobIdRef.get();
|
||||
if (jobId != null) {
|
||||
log.debug(
|
||||
"Recording retry attempt for job {} in TaskManager",
|
||||
jobId);
|
||||
// Retry info is tracked in TaskManager for REST API access
|
||||
try {
|
||||
Thread.sleep(delayMs);
|
||||
} catch (InterruptedException e) {
|
||||
Thread.currentThread().interrupt();
|
||||
log.debug(
|
||||
"Retry delay interrupted for attempt {}/{}",
|
||||
currentAttempt,
|
||||
maxRetries);
|
||||
break;
|
||||
}
|
||||
} else {
|
||||
// No more retries, we'll throw the exception after the loop
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Use non-blocking delay for all retry attempts to avoid blocking
|
||||
// threads
|
||||
// For sync jobs this avoids starving the tomcat thread pool under
|
||||
// load
|
||||
long delayMs = RETRY_BASE_DELAY.toMillis() * currentAttempt;
|
||||
|
||||
// Execute the retry after a delay through the JobExecutorService
|
||||
// rather than blocking the current thread with sleep
|
||||
CompletableFuture<Object> delayedRetry = new CompletableFuture<>();
|
||||
|
||||
// Use a delayed executor for non-blocking delay
|
||||
CompletableFuture.delayedExecutor(delayMs, TimeUnit.MILLISECONDS)
|
||||
.execute(
|
||||
() -> {
|
||||
// Continue the retry loop in the next iteration
|
||||
// We can't return from here directly since
|
||||
// we're in a Runnable
|
||||
delayedRetry.complete(null);
|
||||
});
|
||||
|
||||
// Wait for the delay to complete before continuing
|
||||
try {
|
||||
delayedRetry.join();
|
||||
} catch (Exception e) {
|
||||
Thread.currentThread().interrupt();
|
||||
break;
|
||||
}
|
||||
} else {
|
||||
// No more retries, we'll throw the exception after the loop
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// If we get here, all retries failed
|
||||
if (lastException != null) {
|
||||
// Rethrow RuntimeException as-is to preserve exception type
|
||||
if (lastException instanceof RuntimeException) {
|
||||
throw (RuntimeException) lastException;
|
||||
}
|
||||
// Wrap checked exceptions - GlobalExceptionHandler will unwrap
|
||||
// BaseAppException
|
||||
throw new RuntimeException(
|
||||
"Job failed after "
|
||||
+ maxRetries
|
||||
+ " attempts: "
|
||||
+ lastException.getMessage(),
|
||||
lastException);
|
||||
}
|
||||
// If we get here, all retries failed
|
||||
if (lastException != null) {
|
||||
// Rethrow RuntimeException as-is to preserve exception type
|
||||
if (lastException instanceof RuntimeException) {
|
||||
throw (RuntimeException) lastException;
|
||||
}
|
||||
// Wrap checked exceptions - GlobalExceptionHandler will unwrap
|
||||
// BaseAppException
|
||||
throw new RuntimeException(
|
||||
"Job failed after "
|
||||
+ maxRetries
|
||||
+ " attempts: "
|
||||
+ lastException.getMessage(),
|
||||
lastException);
|
||||
}
|
||||
|
||||
// This should never happen if lastException is properly tracked
|
||||
throw new RuntimeException("Job failed but no exception was recorded");
|
||||
},
|
||||
// This should never happen if lastException is properly tracked
|
||||
throw new RuntimeException("Job failed but no exception was recorded");
|
||||
}),
|
||||
timeout,
|
||||
queueable,
|
||||
resourceWeight);
|
||||
@@ -299,4 +298,32 @@ public class AutoJobAspect {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Wraps a supplier to propagate MDC context to background threads. Captures MDC on request
|
||||
* thread and restores it in the background thread. Ensures proper cleanup to prevent context
|
||||
* leakage across jobs in thread pools.
|
||||
*/
|
||||
private <T> Supplier<T> wrapWithMDC(Supplier<T> supplier) {
|
||||
final Map<String, String> captured = MDC.getCopyOfContextMap();
|
||||
return () -> {
|
||||
final Map<String, String> previous = MDC.getCopyOfContextMap();
|
||||
try {
|
||||
// Set the captured context (or clear if none was captured)
|
||||
if (captured != null) {
|
||||
MDC.setContextMap(new HashMap<>(captured));
|
||||
} else {
|
||||
MDC.clear();
|
||||
}
|
||||
return supplier.get();
|
||||
} finally {
|
||||
// Restore previous state (or clear if there was none)
|
||||
if (previous != null) {
|
||||
MDC.setContextMap(previous);
|
||||
} else {
|
||||
MDC.clear();
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -696,7 +696,8 @@ public class ApplicationProperties {
|
||||
|
||||
@Override
|
||||
public String toString() {
|
||||
return """
|
||||
return
|
||||
"""
|
||||
Driver {
|
||||
driverName='%s'
|
||||
}
|
||||
@@ -960,6 +961,12 @@ 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 captureFileHash =
|
||||
false; // Capture SHA-256 hash of files (increases processing time)
|
||||
private boolean capturePdfAuthor =
|
||||
false; // Capture PDF author metadata (increases processing time)
|
||||
private boolean captureOperationResults =
|
||||
false; // Capture operation return values (not recommended, high volume)
|
||||
}
|
||||
|
||||
@Data
|
||||
|
||||
@@ -169,7 +169,10 @@ public class PdfMetadataService {
|
||||
.getAuthor();
|
||||
|
||||
if (userService != null) {
|
||||
author = author.replace("username", userService.getCurrentUsername());
|
||||
String username = userService.getCurrentUsername();
|
||||
if (username != null) {
|
||||
author = author.replace("username", username);
|
||||
}
|
||||
}
|
||||
}
|
||||
pdf.getDocumentInformation().setAuthor(author);
|
||||
|
||||
@@ -99,6 +99,8 @@ dependencies {
|
||||
// Batik only bridge module needed (transitively pulls anim, gvt, util, css, dom, svg-dom)
|
||||
// Replaces batik-all which included unused codec, svggen, transcoder, script modules
|
||||
implementation 'org.apache.xmlgraphics:batik-bridge:1.19'
|
||||
// Required by TwelveMonkeys imageio-batik SPI (SVGImageReaderSpi) during ImageIO init
|
||||
runtimeOnly 'org.apache.xmlgraphics:batik-transcoder:1.19'
|
||||
|
||||
// PDFBox Graphics2D bridge for Batik SVG to PDF conversion
|
||||
implementation 'de.rototor.pdfbox:graphics2d:3.0.5'
|
||||
|
||||
@@ -98,9 +98,12 @@ premium:
|
||||
producer: Stirling-PDF
|
||||
enterpriseFeatures:
|
||||
audit:
|
||||
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
|
||||
enabled: true # Enable audit logging for security and compliance tracking
|
||||
level: 2 # Audit logging level: 0=OFF, 1=BASIC (compress/split/merge/etc and settings), 2=STANDARD (BASIC + user actions, excludes polling), 3=VERBOSE (everything including polling).
|
||||
retentionDays: 90 # Number of days to retain audit logs (0 or negative = infinite retention)
|
||||
captureFileHash: false # Capture SHA-256 hash of uploaded/processed files. Warning: adds 50-200ms per file depending on size. Only enabled independently of audit level.
|
||||
capturePdfAuthor: false # Capture author metadata from PDF documents. Warning: requires PDF parsing which increases processing time. Only enabled independently of audit level.
|
||||
captureOperationResults: false # Capture operation return values and responses in audit log. Warning: not recommended, significantly increases log volume and disk usage. Use only for debugging.
|
||||
databaseNotifications:
|
||||
backups:
|
||||
successful: false # set to 'true' to enable email notifications for successful database backups
|
||||
|
||||
+71
-26
@@ -8,6 +8,7 @@ import org.aspectj.lang.ProceedingJoinPoint;
|
||||
import org.aspectj.lang.annotation.Around;
|
||||
import org.aspectj.lang.annotation.Aspect;
|
||||
import org.aspectj.lang.reflect.MethodSignature;
|
||||
import org.slf4j.MDC;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.web.context.request.RequestContextHolder;
|
||||
import org.springframework.web.context.request.ServletRequestAttributes;
|
||||
@@ -41,33 +42,67 @@ public class AuditAspect {
|
||||
|
||||
// Fast path: use unified check to determine if we should audit
|
||||
// This avoids all data collection if auditing is disabled
|
||||
if (!AuditUtils.shouldAudit(method, auditConfig)) {
|
||||
if (!auditService.shouldAudit(method, auditConfig)) {
|
||||
return joinPoint.proceed();
|
||||
}
|
||||
|
||||
// EARLY CAPTURE: Try to get from MDC first (propagated from background threads)
|
||||
// If not found, capture from SecurityContext on request thread
|
||||
String capturedPrincipal = MDC.get("auditPrincipal");
|
||||
if (capturedPrincipal == null) {
|
||||
// Fallback: Capture from SecurityContext if running in request thread
|
||||
capturedPrincipal = auditService.captureCurrentPrincipal();
|
||||
}
|
||||
|
||||
String capturedOrigin = MDC.get("auditOrigin");
|
||||
if (capturedOrigin == null) {
|
||||
// Fallback: Capture from SecurityContext if running in request thread
|
||||
capturedOrigin = auditService.captureCurrentOrigin();
|
||||
}
|
||||
|
||||
ServletRequestAttributes attrs =
|
||||
(ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
|
||||
HttpServletRequest req = attrs != null ? attrs.getRequest() : null;
|
||||
|
||||
String capturedIp = MDC.get("auditIp");
|
||||
if (capturedIp == null) {
|
||||
// Fallback: Try to extract from request if available
|
||||
capturedIp = auditService.extractClientIp(req);
|
||||
}
|
||||
|
||||
// Only create the map once we know we'll use it
|
||||
Map<String, Object> auditData =
|
||||
AuditUtils.createBaseAuditData(joinPoint, auditedAnnotation.level());
|
||||
auditService.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();
|
||||
AuditUtils.addHttpData(auditData, httpMethod, path, auditedAnnotation.level());
|
||||
AuditUtils.addFileData(auditData, joinPoint, auditedAnnotation.level());
|
||||
auditService.addHttpData(auditData, httpMethod, path, auditedAnnotation.level());
|
||||
auditService.addFileData(auditData, joinPoint, auditedAnnotation.level());
|
||||
|
||||
// File operation details logged at DEBUG level for verification
|
||||
if (auditData.containsKey("files") || auditData.containsKey("filename")) {
|
||||
log.debug(
|
||||
"@Audited method file operation - Principal: {}, Origin: {}, IP: {}, Method: {}, Path: {}, Files: {}",
|
||||
capturedPrincipal,
|
||||
capturedOrigin,
|
||||
capturedIp,
|
||||
httpMethod,
|
||||
path,
|
||||
auditData.getOrDefault("files", auditData.getOrDefault("filename", "N/A")));
|
||||
}
|
||||
if (auditData.containsKey("fileHash") || auditData.containsKey("hash")) {
|
||||
log.debug(
|
||||
"@Audited file hash captured - Hash: {}, Document: {}",
|
||||
auditData.getOrDefault("fileHash", auditData.getOrDefault("hash", "N/A")),
|
||||
auditData.getOrDefault("filename", "N/A"));
|
||||
}
|
||||
}
|
||||
|
||||
// Add arguments if requested and if at VERBOSE level, or if specifically requested
|
||||
boolean includeArgs =
|
||||
auditedAnnotation.includeArgs()
|
||||
&& (auditedAnnotation.level() == AuditLevel.VERBOSE
|
||||
|| auditConfig.getAuditLevel() == AuditLevel.VERBOSE);
|
||||
|
||||
if (includeArgs) {
|
||||
AuditUtils.addMethodArguments(auditData, joinPoint, AuditLevel.VERBOSE);
|
||||
// Add method arguments if requested (captured at all audit levels for operational context)
|
||||
if (auditedAnnotation.includeArgs()) {
|
||||
auditService.addMethodArguments(auditData, joinPoint, auditedAnnotation.level());
|
||||
}
|
||||
|
||||
// Record start time for latency calculation
|
||||
@@ -80,15 +115,14 @@ public class AuditAspect {
|
||||
// Add success status
|
||||
auditData.put("status", "success");
|
||||
|
||||
// Add result if requested and if at VERBOSE level
|
||||
// Add result only if requested in annotation AND operation result capture is enabled
|
||||
boolean includeResult =
|
||||
auditedAnnotation.includeResult()
|
||||
&& (auditedAnnotation.level() == AuditLevel.VERBOSE
|
||||
|| auditConfig.getAuditLevel() == AuditLevel.VERBOSE);
|
||||
&& auditService.shouldCaptureOperationResults();
|
||||
|
||||
if (includeResult && result != null) {
|
||||
// Use safe string conversion with size limiting
|
||||
auditData.put("result", AuditUtils.safeToString(result, 1000));
|
||||
auditData.put("result", auditService.safeToString(result, 1000));
|
||||
}
|
||||
|
||||
return result;
|
||||
@@ -105,20 +139,19 @@ public class AuditAspect {
|
||||
// methods
|
||||
HttpServletResponse resp = attrs != null ? attrs.getResponse() : null;
|
||||
boolean isHttpRequest = attrs != null;
|
||||
AuditUtils.addTimingData(
|
||||
auditService.addTimingData(
|
||||
auditData, startTime, resp, auditedAnnotation.level(), isHttpRequest);
|
||||
|
||||
// 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();
|
||||
}
|
||||
|
||||
AuditEventType eventType =
|
||||
AuditUtils.resolveEventType(
|
||||
auditService.resolveEventType(
|
||||
method,
|
||||
joinPoint.getTarget().getClass(),
|
||||
path,
|
||||
@@ -128,11 +161,23 @@ 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 with early-captured values
|
||||
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 with early-captured values
|
||||
auditService.audit(
|
||||
capturedPrincipal,
|
||||
capturedOrigin,
|
||||
capturedIp,
|
||||
eventType,
|
||||
auditData,
|
||||
auditedAnnotation.level());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,6 +22,9 @@ public enum AuditEventType {
|
||||
// PDF operations - STANDARD level
|
||||
PDF_PROCESS("PDF processing operation"),
|
||||
|
||||
// UI data requests - STANDARD level
|
||||
UI_DATA("UI data request"),
|
||||
|
||||
// HTTP requests - STANDARD level
|
||||
HTTP_REQUEST("HTTP request");
|
||||
|
||||
|
||||
@@ -12,21 +12,26 @@ public enum AuditLevel {
|
||||
OFF(0),
|
||||
|
||||
/**
|
||||
* BASIC - Minimal audit logging (level 1) Includes: - Authentication events (login, logout,
|
||||
* failed logins) - Password changes - User/role changes - System configuration changes
|
||||
* BASIC - File modifications only (level 1) Tracks: PDF file operations like compress, split,
|
||||
* merge, etc., and settings changes. Captures: Operation status (success/failure), method
|
||||
* parameters, timing. Ideal for: Compliance tracking of file modifications with minimal log
|
||||
* volume.
|
||||
*/
|
||||
BASIC(1),
|
||||
|
||||
/**
|
||||
* STANDARD - Standard audit logging (level 2) Includes everything in BASIC plus: - All HTTP
|
||||
* requests (basic info: URL, method, status) - File operations (upload, download, process) -
|
||||
* PDF operations (view, edit, etc.) - User operations
|
||||
* STANDARD - File operations and user actions (level 2) Tracks: Everything in BASIC plus user
|
||||
* actions like login/logout, account changes, and general GET requests. Excludes continuous
|
||||
* polling calls (e.g., auth/me, app-config, health, metrics endpoints). Ideal for: General
|
||||
* audit trail with reasonable log volume for most deployments.
|
||||
*/
|
||||
STANDARD(2),
|
||||
|
||||
/**
|
||||
* VERBOSE - Detailed audit logging (level 3) Includes everything in STANDARD plus: - Request
|
||||
* headers and parameters - Method parameters - Operation results - Detailed timing information
|
||||
* VERBOSE - Everything including polling (level 3) Tracks: Everything in STANDARD plus
|
||||
* continuous polling calls and all GET requests. Captures: Detailed timing information. Note:
|
||||
* Operation results (return values) are controlled by separate captureOperationResults flag.
|
||||
* Warning: High log volume and performance impact.
|
||||
*/
|
||||
VERBOSE(3);
|
||||
|
||||
|
||||
@@ -1,427 +0,0 @@
|
||||
package stirling.software.proprietary.audit;
|
||||
|
||||
import java.lang.reflect.Method;
|
||||
import java.time.Instant;
|
||||
import java.util.Arrays;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
import java.util.stream.Collectors;
|
||||
import java.util.stream.IntStream;
|
||||
|
||||
import org.apache.commons.lang3.StringUtils;
|
||||
import org.aspectj.lang.ProceedingJoinPoint;
|
||||
import org.aspectj.lang.reflect.MethodSignature;
|
||||
import org.slf4j.MDC;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.security.core.Authentication;
|
||||
import org.springframework.security.core.context.SecurityContextHolder;
|
||||
import org.springframework.web.context.request.RequestContextHolder;
|
||||
import org.springframework.web.context.request.ServletRequestAttributes;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import jakarta.servlet.http.HttpServletResponse;
|
||||
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import stirling.software.common.util.RegexPatternUtils;
|
||||
import stirling.software.common.util.RequestUriUtils;
|
||||
import stirling.software.proprietary.config.AuditConfigurationProperties;
|
||||
|
||||
/**
|
||||
* Shared utilities for audit aspects to ensure consistent behavior across different audit
|
||||
* mechanisms.
|
||||
*/
|
||||
@Slf4j
|
||||
public class AuditUtils {
|
||||
|
||||
/**
|
||||
* Create a standard audit data map with common attributes based on the current audit level
|
||||
*
|
||||
* @param joinPoint The AspectJ join point
|
||||
* @param auditLevel The current audit level
|
||||
* @return A map with standard audit data
|
||||
*/
|
||||
public static Map<String, Object> createBaseAuditData(
|
||||
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());
|
||||
data.put(
|
||||
"methodName",
|
||||
((MethodSignature) joinPoint.getSignature()).getMethod().getName());
|
||||
}
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Add HTTP-specific information to the audit data if available
|
||||
*
|
||||
* @param data The existing audit data map
|
||||
* @param httpMethod The HTTP method (GET, POST, etc.)
|
||||
* @param path The request path
|
||||
* @param auditLevel The current audit level
|
||||
*/
|
||||
public static void addHttpData(
|
||||
Map<String, Object> data, String httpMethod, String path, AuditLevel auditLevel) {
|
||||
if (httpMethod == null || path == null) {
|
||||
return; // Skip if we don't have basic HTTP info
|
||||
}
|
||||
|
||||
// BASIC level HTTP data
|
||||
data.put("httpMethod", httpMethod);
|
||||
data.put("path", path);
|
||||
|
||||
// Get request attributes safely
|
||||
ServletRequestAttributes attrs =
|
||||
(ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
|
||||
if (attrs == null) {
|
||||
return; // No request context available
|
||||
}
|
||||
|
||||
HttpServletRequest req = attrs.getRequest();
|
||||
if (req == null) {
|
||||
return; // No request available
|
||||
}
|
||||
|
||||
// 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);
|
||||
data.put("requestId", MDC.get("requestId"));
|
||||
|
||||
// Form data for POST/PUT/PATCH
|
||||
if (("POST".equalsIgnoreCase(httpMethod)
|
||||
|| "PUT".equalsIgnoreCase(httpMethod)
|
||||
|| "PATCH".equalsIgnoreCase(httpMethod))
|
||||
&& req.getContentType() != null) {
|
||||
|
||||
String contentType = req.getContentType();
|
||||
if (contentType.contains(MediaType.APPLICATION_FORM_URLENCODED_VALUE)
|
||||
|| contentType.contains(MediaType.MULTIPART_FORM_DATA_VALUE)) {
|
||||
|
||||
Map<String, String[]> params = new HashMap<>(req.getParameterMap());
|
||||
// Remove CSRF token from logged parameters
|
||||
params.remove("_csrf");
|
||||
|
||||
if (!params.isEmpty()) {
|
||||
data.put("formParams", params);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Add file information to the audit data if available
|
||||
*
|
||||
* @param data The existing audit data map
|
||||
* @param joinPoint The AspectJ join point
|
||||
* @param auditLevel The current audit level
|
||||
*/
|
||||
public static void addFileData(
|
||||
Map<String, Object> data, ProceedingJoinPoint joinPoint, AuditLevel auditLevel) {
|
||||
if (auditLevel.includes(AuditLevel.STANDARD)) {
|
||||
List<MultipartFile> files =
|
||||
Arrays.stream(joinPoint.getArgs())
|
||||
.filter(a -> a instanceof MultipartFile)
|
||||
.map(a -> (MultipartFile) a)
|
||||
.collect(Collectors.toList());
|
||||
|
||||
if (!files.isEmpty()) {
|
||||
List<Map<String, Object>> fileInfos =
|
||||
files.stream()
|
||||
.map(
|
||||
f -> {
|
||||
Map<String, Object> m = new HashMap<>();
|
||||
m.put("name", f.getOriginalFilename());
|
||||
m.put("size", f.getSize());
|
||||
m.put("type", f.getContentType());
|
||||
return m;
|
||||
})
|
||||
.collect(Collectors.toList());
|
||||
|
||||
data.put("files", fileInfos);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Add method arguments to the audit data
|
||||
*
|
||||
* @param data The existing audit data map
|
||||
* @param joinPoint The AspectJ join point
|
||||
* @param auditLevel The current audit level
|
||||
*/
|
||||
public static void addMethodArguments(
|
||||
Map<String, Object> data, ProceedingJoinPoint joinPoint, AuditLevel auditLevel) {
|
||||
if (auditLevel.includes(AuditLevel.VERBOSE)) {
|
||||
MethodSignature sig = (MethodSignature) joinPoint.getSignature();
|
||||
String[] names = sig.getParameterNames();
|
||||
Object[] vals = joinPoint.getArgs();
|
||||
if (names != null && vals != null) {
|
||||
IntStream.range(0, names.length)
|
||||
.forEach(
|
||||
i -> {
|
||||
if (vals[i] != null) {
|
||||
// Convert objects to safe string representation
|
||||
data.put("arg_" + names[i], safeToString(vals[i], 500));
|
||||
} else {
|
||||
data.put("arg_" + names[i], null);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Safely convert an object to string with size limiting
|
||||
*
|
||||
* @param obj The object to convert
|
||||
* @param maxLength Maximum length of the resulting string
|
||||
* @return A safe string representation, truncated if needed
|
||||
*/
|
||||
public static String safeToString(Object obj, int maxLength) {
|
||||
if (obj == null) {
|
||||
return "null";
|
||||
}
|
||||
|
||||
String result;
|
||||
try {
|
||||
// Handle common types directly to avoid toString() overhead
|
||||
if (obj instanceof String) {
|
||||
result = (String) obj;
|
||||
} else if (obj instanceof Number || obj instanceof Boolean) {
|
||||
result = obj.toString();
|
||||
} else if (obj instanceof byte[]) {
|
||||
result = "[binary data length=" + ((byte[]) obj).length + "]";
|
||||
} else {
|
||||
// For complex objects, use toString but handle exceptions
|
||||
result = obj.toString();
|
||||
}
|
||||
|
||||
// Truncate if necessary
|
||||
if (result != null && result.length() > maxLength) {
|
||||
return StringUtils.truncate(result, maxLength - 3) + "...";
|
||||
}
|
||||
|
||||
return result;
|
||||
} catch (Exception e) {
|
||||
// If toString() fails, return the class name
|
||||
return "[" + obj.getClass().getName() + " - toString() failed]";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine if a method should be audited based on config and annotation
|
||||
*
|
||||
* @param method The method to check
|
||||
* @param auditConfig The audit configuration
|
||||
* @return true if the method should be audited
|
||||
*/
|
||||
public static boolean shouldAudit(Method method, AuditConfigurationProperties auditConfig) {
|
||||
// First check if audit is globally enabled - fast path
|
||||
if (!auditConfig.isEnabled()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check for annotation override
|
||||
Audited auditedAnnotation = method.getAnnotation(Audited.class);
|
||||
AuditLevel requiredLevel =
|
||||
(auditedAnnotation != null) ? auditedAnnotation.level() : AuditLevel.BASIC;
|
||||
|
||||
// Check if the required level is enabled
|
||||
return auditConfig.getAuditLevel().includes(requiredLevel);
|
||||
}
|
||||
|
||||
/**
|
||||
* Add timing and response status data to the audit record
|
||||
*
|
||||
* @param data The audit data to add to
|
||||
* @param startTime The start time in milliseconds
|
||||
* @param response The HTTP response (may be null for non-HTTP methods)
|
||||
* @param level The current audit level
|
||||
* @param isHttpRequest Whether this is an HTTP request (controller) or a regular method call
|
||||
*/
|
||||
public static void addTimingData(
|
||||
Map<String, Object> data,
|
||||
long startTime,
|
||||
HttpServletResponse response,
|
||||
AuditLevel level,
|
||||
boolean isHttpRequest) {
|
||||
if (level.includes(AuditLevel.STANDARD)) {
|
||||
// For HTTP requests, let ControllerAuditAspect handle timing separately
|
||||
// For non-HTTP methods, add execution time here
|
||||
if (!isHttpRequest) {
|
||||
data.put("latencyMs", System.currentTimeMillis() - startTime);
|
||||
}
|
||||
|
||||
// Add HTTP status code if available
|
||||
if (response != null) {
|
||||
try {
|
||||
data.put("statusCode", response.getStatus());
|
||||
} catch (Exception e) {
|
||||
// Ignore - response might be in an inconsistent state
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the event type to use for auditing, considering annotations and context
|
||||
*
|
||||
* @param method The method being audited
|
||||
* @param controller The controller class
|
||||
* @param path The request path (may be null for non-HTTP methods)
|
||||
* @param httpMethod The HTTP method (may be null for non-HTTP methods)
|
||||
* @param annotation The @Audited annotation (may be null)
|
||||
* @return The resolved event type (never null)
|
||||
*/
|
||||
public static AuditEventType resolveEventType(
|
||||
Method method,
|
||||
Class<?> controller,
|
||||
String path,
|
||||
String httpMethod,
|
||||
Audited annotation) {
|
||||
// First check if we have an explicit annotation
|
||||
if (annotation != null && annotation.type() != AuditEventType.HTTP_REQUEST) {
|
||||
return annotation.type();
|
||||
}
|
||||
|
||||
// For HTTP methods, infer based on controller and path
|
||||
if (httpMethod != null && path != null) {
|
||||
String cls = controller.getSimpleName().toLowerCase(Locale.ROOT);
|
||||
String pkg = controller.getPackage().getName().toLowerCase(Locale.ROOT);
|
||||
|
||||
if ("GET".equals(httpMethod)) return AuditEventType.HTTP_REQUEST;
|
||||
|
||||
if (cls.contains("user")
|
||||
|| cls.contains("auth")
|
||||
|| pkg.contains("auth")
|
||||
|| path.startsWith("/user")
|
||||
|| path.startsWith("/login")) {
|
||||
return AuditEventType.USER_PROFILE_UPDATE;
|
||||
} else if (cls.contains("admin")
|
||||
|| path.startsWith("/admin")
|
||||
|| path.startsWith("/settings")) {
|
||||
return AuditEventType.SETTINGS_CHANGED;
|
||||
} else if (cls.contains("file")
|
||||
|| path.startsWith("/file")
|
||||
|| RegexPatternUtils.getInstance()
|
||||
.getUploadDownloadPathPattern()
|
||||
.matcher(path)
|
||||
.matches()) {
|
||||
return AuditEventType.FILE_OPERATION;
|
||||
}
|
||||
}
|
||||
|
||||
// Default for non-HTTP methods or when no specific match
|
||||
return AuditEventType.PDF_PROCESS;
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine the appropriate audit level to use
|
||||
*
|
||||
* @param method The method to check
|
||||
* @param defaultLevel The default level to use if no annotation present
|
||||
* @param auditConfig The audit configuration
|
||||
* @return The audit level to use
|
||||
*/
|
||||
public static AuditLevel getEffectiveAuditLevel(
|
||||
Method method, AuditLevel defaultLevel, AuditConfigurationProperties auditConfig) {
|
||||
Audited auditedAnnotation = method.getAnnotation(Audited.class);
|
||||
if (auditedAnnotation != null) {
|
||||
// Method has @Audited - use its level
|
||||
return auditedAnnotation.level();
|
||||
}
|
||||
|
||||
// Use default level (typically from global config)
|
||||
return defaultLevel;
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine the appropriate audit event type to use
|
||||
*
|
||||
* @param method The method being audited
|
||||
* @param controller The controller class
|
||||
* @param path The request path
|
||||
* @param httpMethod The HTTP method
|
||||
* @return The determined audit event type
|
||||
*/
|
||||
public static AuditEventType determineAuditEventType(
|
||||
Method method, Class<?> controller, String path, String httpMethod) {
|
||||
// First check for explicit annotation
|
||||
Audited auditedAnnotation = method.getAnnotation(Audited.class);
|
||||
if (auditedAnnotation != null) {
|
||||
return auditedAnnotation.type();
|
||||
}
|
||||
|
||||
// Otherwise infer from controller and path
|
||||
String cls = controller.getSimpleName().toLowerCase(Locale.ROOT);
|
||||
String pkg = controller.getPackage().getName().toLowerCase(Locale.ROOT);
|
||||
|
||||
if ("GET".equals(httpMethod)) return AuditEventType.HTTP_REQUEST;
|
||||
|
||||
if (cls.contains("user")
|
||||
|| cls.contains("auth")
|
||||
|| pkg.contains("auth")
|
||||
|| path.startsWith("/user")
|
||||
|| path.startsWith("/login")) {
|
||||
return AuditEventType.USER_PROFILE_UPDATE;
|
||||
} else if (cls.contains("admin")
|
||||
|| path.startsWith("/admin")
|
||||
|| path.startsWith("/settings")) {
|
||||
return AuditEventType.SETTINGS_CHANGED;
|
||||
} else if (cls.contains("file")
|
||||
|| path.startsWith("/file")
|
||||
|| RegexPatternUtils.getInstance()
|
||||
.getUploadDownloadPathPattern()
|
||||
.matcher(path)
|
||||
.matches()) {
|
||||
return AuditEventType.FILE_OPERATION;
|
||||
} else {
|
||||
return AuditEventType.PDF_PROCESS;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the current HTTP request if available
|
||||
*
|
||||
* @return The current request or null if not in a request context
|
||||
*/
|
||||
public static HttpServletRequest getCurrentRequest() {
|
||||
ServletRequestAttributes attrs =
|
||||
(ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
|
||||
return attrs != null ? attrs.getRequest() : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a GET request is for a static resource
|
||||
*
|
||||
* @param request The HTTP request
|
||||
* @return true if this is a static resource request
|
||||
*/
|
||||
public static boolean isStaticResourceRequest(HttpServletRequest request) {
|
||||
return request != null
|
||||
&& !RequestUriUtils.isTrackableResource(
|
||||
request.getContextPath(), request.getRequestURI());
|
||||
}
|
||||
}
|
||||
+142
-59
@@ -9,6 +9,7 @@ import org.aspectj.lang.ProceedingJoinPoint;
|
||||
import org.aspectj.lang.annotation.Around;
|
||||
import org.aspectj.lang.annotation.Aspect;
|
||||
import org.aspectj.lang.reflect.MethodSignature;
|
||||
import org.slf4j.MDC;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.web.bind.annotation.DeleteMapping;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
@@ -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 BEFORE AutoJobAspect to populate MDC
|
||||
public class ControllerAuditAspect {
|
||||
|
||||
private final AuditService auditService;
|
||||
@@ -92,7 +93,7 @@ public class ControllerAuditAspect {
|
||||
|
||||
// Fast path: check if auditing is enabled before doing any work
|
||||
// This avoids all data collection if auditing is disabled
|
||||
if (!AuditUtils.shouldAudit(method, auditConfig)) {
|
||||
if (!auditService.shouldAudit(method, auditConfig)) {
|
||||
return joinPoint.proceed();
|
||||
}
|
||||
|
||||
@@ -110,8 +111,14 @@ public class ControllerAuditAspect {
|
||||
|
||||
// Skip static GET resources
|
||||
if ("GET".equals(httpMethod)) {
|
||||
HttpServletRequest maybe = AuditUtils.getCurrentRequest();
|
||||
if (maybe != null && AuditUtils.isStaticResourceRequest(maybe)) {
|
||||
HttpServletRequest maybe = auditService.getCurrentRequest();
|
||||
if (maybe != null && auditService.isStaticResourceRequest(maybe)) {
|
||||
return joinPoint.proceed();
|
||||
}
|
||||
// Skip polling calls at STANDARD level (exclude from audit log noise)
|
||||
if (maybe != null
|
||||
&& auditService.isPollingCall(maybe)
|
||||
&& auditConfig.getAuditLevel() == AuditLevel.STANDARD) {
|
||||
return joinPoint.proceed();
|
||||
}
|
||||
}
|
||||
@@ -121,75 +128,143 @@ public class ControllerAuditAspect {
|
||||
HttpServletRequest req = attrs != null ? attrs.getRequest() : null;
|
||||
HttpServletResponse resp = attrs != null ? attrs.getResponse() : null;
|
||||
|
||||
long start = System.currentTimeMillis();
|
||||
String previousPrincipal = MDC.get("auditPrincipal");
|
||||
String previousOrigin = MDC.get("auditOrigin");
|
||||
String previousIp = MDC.get("auditIp");
|
||||
|
||||
// Use AuditUtils to create the base audit data
|
||||
Map<String, Object> data = AuditUtils.createBaseAuditData(joinPoint, level);
|
||||
|
||||
// Add HTTP-specific information
|
||||
AuditUtils.addHttpData(data, httpMethod, path, level);
|
||||
|
||||
// Add file information if present
|
||||
AuditUtils.addFileData(data, joinPoint, level);
|
||||
|
||||
// Add method arguments if at VERBOSE level
|
||||
if (level.includes(AuditLevel.VERBOSE)) {
|
||||
AuditUtils.addMethodArguments(data, joinPoint, level);
|
||||
// EARLY CAPTURE: Capture from SecurityContext on request thread, store in MDC for async
|
||||
// propagation
|
||||
// MDC.put is necessary for background threads to inherit audit context
|
||||
String capturedPrincipal = previousPrincipal;
|
||||
if (capturedPrincipal == null) {
|
||||
capturedPrincipal = auditService.captureCurrentPrincipal();
|
||||
MDC.put("auditPrincipal", capturedPrincipal);
|
||||
}
|
||||
|
||||
Object result = null;
|
||||
String capturedOrigin = previousOrigin;
|
||||
if (capturedOrigin == null) {
|
||||
capturedOrigin = auditService.captureCurrentOrigin();
|
||||
MDC.put("auditOrigin", capturedOrigin);
|
||||
}
|
||||
|
||||
String capturedIp = previousIp;
|
||||
if (capturedIp == null && req != null) {
|
||||
capturedIp = auditService.extractClientIp(req);
|
||||
if (capturedIp != null) {
|
||||
MDC.put("auditIp", capturedIp);
|
||||
}
|
||||
}
|
||||
try {
|
||||
result = joinPoint.proceed();
|
||||
data.put("outcome", "success");
|
||||
} catch (Throwable ex) {
|
||||
data.put("outcome", "failure");
|
||||
data.put("errorType", ex.getClass().getSimpleName());
|
||||
data.put("errorMessage", ex.getMessage());
|
||||
throw ex;
|
||||
} finally {
|
||||
// Handle timing directly for HTTP requests
|
||||
if (level.includes(AuditLevel.STANDARD)) {
|
||||
data.put("latencyMs", System.currentTimeMillis() - start);
|
||||
if (resp != null) data.put("statusCode", resp.getStatus());
|
||||
}
|
||||
|
||||
// Call AuditUtils but with isHttpRequest=true to skip additional timing
|
||||
AuditUtils.addTimingData(data, start, resp, level, true);
|
||||
|
||||
// Add result for VERBOSE level
|
||||
if (level.includes(AuditLevel.VERBOSE) && result != null) {
|
||||
// Use safe string conversion with size limiting
|
||||
data.put("result", AuditUtils.safeToString(result, 1000));
|
||||
}
|
||||
|
||||
// Resolve the event type using the unified method
|
||||
AuditEventType eventType =
|
||||
AuditUtils.resolveEventType(
|
||||
method,
|
||||
joinPoint.getTarget().getClass(),
|
||||
path,
|
||||
httpMethod,
|
||||
auditedAnnotation);
|
||||
|
||||
// Check if we should use string type instead (for backward compatibility)
|
||||
// Avoid duplicate events for controller methods explicitly annotated with @Audited.
|
||||
// @Audited methods are audited by AuditAspect.
|
||||
if (auditedAnnotation != null) {
|
||||
String typeString = auditedAnnotation.typeString();
|
||||
if (eventType == AuditEventType.HTTP_REQUEST
|
||||
&& StringUtils.isNotEmpty(typeString)) {
|
||||
auditService.audit(typeString, data, level);
|
||||
return result;
|
||||
return joinPoint.proceed();
|
||||
}
|
||||
|
||||
long start = System.currentTimeMillis();
|
||||
|
||||
// Use auditService to create the base audit data
|
||||
Map<String, Object> data = auditService.createBaseAuditData(joinPoint, level);
|
||||
|
||||
// Add HTTP-specific information
|
||||
auditService.addHttpData(data, httpMethod, path, level);
|
||||
|
||||
// Add file information if present
|
||||
auditService.addFileData(data, joinPoint, level);
|
||||
|
||||
// Add method arguments if at VERBOSE level
|
||||
if (level.includes(AuditLevel.VERBOSE)) {
|
||||
auditService.addMethodArguments(data, joinPoint, level);
|
||||
}
|
||||
|
||||
Object result = null;
|
||||
try {
|
||||
result = joinPoint.proceed();
|
||||
data.put("outcome", "success");
|
||||
} catch (Throwable ex) {
|
||||
data.put("outcome", "failure");
|
||||
data.put("errorType", ex.getClass().getSimpleName());
|
||||
data.put("errorMessage", ex.getMessage());
|
||||
throw ex;
|
||||
} finally {
|
||||
// Handle timing directly for HTTP requests
|
||||
if (level.includes(AuditLevel.STANDARD)) {
|
||||
data.put("latencyMs", System.currentTimeMillis() - start);
|
||||
if (resp != null) data.put("statusCode", resp.getStatus());
|
||||
}
|
||||
|
||||
// Call auditService but with isHttpRequest=true to skip additional timing
|
||||
auditService.addTimingData(data, start, resp, level, true);
|
||||
|
||||
// Resolve the event type using the unified method
|
||||
AuditEventType eventType =
|
||||
auditService.resolveEventType(
|
||||
method,
|
||||
joinPoint.getTarget().getClass(),
|
||||
path,
|
||||
httpMethod,
|
||||
auditedAnnotation);
|
||||
|
||||
// Add result only if operation result capture is explicitly enabled
|
||||
// Skip result for UI_DATA events to avoid storing large response bodies
|
||||
if (auditService.shouldCaptureOperationResults()
|
||||
&& result != null
|
||||
&& eventType != AuditEventType.UI_DATA) {
|
||||
// Use safe string conversion with size limiting
|
||||
data.put("result", auditService.safeToString(result, 1000));
|
||||
}
|
||||
|
||||
// Check if we should use string type instead (for backward compatibility)
|
||||
if (auditedAnnotation != null) {
|
||||
String typeString = auditedAnnotation.typeString();
|
||||
if (eventType == AuditEventType.HTTP_REQUEST
|
||||
&& StringUtils.isNotEmpty(typeString)) {
|
||||
auditService.audit(
|
||||
capturedPrincipal,
|
||||
capturedOrigin,
|
||||
capturedIp,
|
||||
typeString,
|
||||
data,
|
||||
level);
|
||||
} else {
|
||||
// Use the enum type with early-captured values
|
||||
auditService.audit(
|
||||
capturedPrincipal,
|
||||
capturedOrigin,
|
||||
capturedIp,
|
||||
eventType,
|
||||
data,
|
||||
level);
|
||||
}
|
||||
} else {
|
||||
// Use the enum type with early-captured values
|
||||
auditService.audit(
|
||||
capturedPrincipal, capturedOrigin, capturedIp, eventType, data, level);
|
||||
}
|
||||
}
|
||||
|
||||
// Use the enum type
|
||||
auditService.audit(eventType, data, level);
|
||||
return result;
|
||||
} finally {
|
||||
restoreMdcValue("auditPrincipal", previousPrincipal);
|
||||
restoreMdcValue("auditOrigin", previousOrigin);
|
||||
restoreMdcValue("auditIp", previousIp);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
// Using AuditUtils.determineAuditEventType instead
|
||||
|
||||
private String getRequestPath(Method method, String httpMethod) {
|
||||
// Prefer actual request URI over annotation patterns (which may contain regex)
|
||||
ServletRequestAttributes attrs =
|
||||
(ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
|
||||
if (attrs != null) {
|
||||
HttpServletRequest request = attrs.getRequest();
|
||||
if (request != null) {
|
||||
return request.getRequestURI();
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: reconstruct from annotations when not in web context
|
||||
String base = "";
|
||||
RequestMapping cm = method.getDeclaringClass().getAnnotation(RequestMapping.class);
|
||||
if (cm != null && cm.value().length > 0) base = cm.value()[0];
|
||||
@@ -211,5 +286,13 @@ public class ControllerAuditAspect {
|
||||
return base + mp;
|
||||
}
|
||||
|
||||
private void restoreMdcValue(String key, String previousValue) {
|
||||
if (previousValue != null) {
|
||||
MDC.put(key, previousValue);
|
||||
} else {
|
||||
MDC.remove(key);
|
||||
}
|
||||
}
|
||||
|
||||
// Using AuditUtils.getCurrentRequest instead
|
||||
}
|
||||
|
||||
+13
-2
@@ -23,6 +23,9 @@ public class AuditConfigurationProperties {
|
||||
private final boolean enabled;
|
||||
private final int level;
|
||||
private final int retentionDays;
|
||||
private final boolean captureFileHash;
|
||||
private final boolean capturePdfAuthor;
|
||||
private final boolean captureOperationResults;
|
||||
|
||||
public AuditConfigurationProperties(ApplicationProperties applicationProperties) {
|
||||
ApplicationProperties.Premium.EnterpriseFeatures.Audit auditConfig =
|
||||
@@ -37,11 +40,19 @@ public class AuditConfigurationProperties {
|
||||
// Retention days (0 means infinite)
|
||||
this.retentionDays = auditConfig.getRetentionDays();
|
||||
|
||||
// Metadata and detail capture flags
|
||||
this.captureFileHash = auditConfig.isCaptureFileHash();
|
||||
this.capturePdfAuthor = auditConfig.isCapturePdfAuthor();
|
||||
this.captureOperationResults = auditConfig.isCaptureOperationResults();
|
||||
|
||||
log.debug(
|
||||
"Initialized audit configuration: enabled={}, level={}, retentionDays={} (0=infinite)",
|
||||
"Initialized audit configuration: enabled={}, level={}, retentionDays={} (0=infinite), fileHash={}, pdfAuthor={}, operationResults={}",
|
||||
this.enabled,
|
||||
this.level,
|
||||
this.retentionDays);
|
||||
this.retentionDays,
|
||||
this.captureFileHash,
|
||||
this.capturePdfAuthor,
|
||||
this.captureOperationResults);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
+548
-65
@@ -1,5 +1,6 @@
|
||||
package stirling.software.proprietary.controller.api;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.time.Instant;
|
||||
import java.time.LocalDate;
|
||||
import java.time.LocalDateTime;
|
||||
@@ -18,6 +19,7 @@ import org.springframework.http.MediaType;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.security.access.prepost.PreAuthorize;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
@@ -44,12 +46,13 @@ public class AuditRestController {
|
||||
private final ObjectMapper objectMapper;
|
||||
|
||||
/**
|
||||
* Get audit events with pagination and filters. Maps to frontend's getEvents() call.
|
||||
* Get audit events with pagination and filters. Maps to frontend's getEvents() call. Supports
|
||||
* both single values and multi-select arrays for eventType and username.
|
||||
*
|
||||
* @param page Page number (0-indexed)
|
||||
* @param pageSize Number of items per page
|
||||
* @param eventType Filter by event type
|
||||
* @param username Filter by username (principal)
|
||||
* @param eventType Filter by event type(s) - can be single value or array
|
||||
* @param username Filter by username(s) - can be single value or array
|
||||
* @param startDate Filter start date
|
||||
* @param endDate Filter end date
|
||||
* @return Paginated audit events response
|
||||
@@ -58,8 +61,8 @@ public class AuditRestController {
|
||||
public ResponseEntity<AuditEventsResponse> getAuditEvents(
|
||||
@RequestParam(value = "page", defaultValue = "0") int page,
|
||||
@RequestParam(value = "pageSize", defaultValue = "30") int pageSize,
|
||||
@RequestParam(value = "eventType", required = false) String eventType,
|
||||
@RequestParam(value = "username", required = false) String username,
|
||||
@RequestParam(value = "eventType", required = false) String[] eventTypes,
|
||||
@RequestParam(value = "username", required = false) String[] usernames,
|
||||
@RequestParam(value = "startDate", required = false)
|
||||
@DateTimeFormat(iso = DateTimeFormat.ISO.DATE)
|
||||
LocalDate startDate,
|
||||
@@ -70,33 +73,45 @@ public class AuditRestController {
|
||||
Pageable pageable = PageRequest.of(page, pageSize, Sort.by("timestamp").descending());
|
||||
Page<PersistentAuditEvent> events;
|
||||
|
||||
// Convert arrays to lists
|
||||
List<String> eventTypeList =
|
||||
(eventTypes != null && eventTypes.length > 0) ? Arrays.asList(eventTypes) : null;
|
||||
List<String> usernameList =
|
||||
(usernames != null && usernames.length > 0) ? Arrays.asList(usernames) : null;
|
||||
|
||||
Instant startInstant = null;
|
||||
Instant endInstant = null;
|
||||
if (startDate != null && endDate != null) {
|
||||
startInstant = startDate.atStartOfDay(ZoneId.systemDefault()).toInstant();
|
||||
endInstant = endDate.plusDays(1).atStartOfDay(ZoneId.systemDefault()).toInstant();
|
||||
}
|
||||
|
||||
// Apply filters based on provided parameters
|
||||
if (eventType != null && username != null && startDate != null && endDate != null) {
|
||||
Instant start = startDate.atStartOfDay(ZoneId.systemDefault()).toInstant();
|
||||
Instant end = endDate.plusDays(1).atStartOfDay(ZoneId.systemDefault()).toInstant();
|
||||
if (eventTypeList != null
|
||||
&& usernameList != null
|
||||
&& startInstant != null
|
||||
&& endInstant != null) {
|
||||
events =
|
||||
auditRepository.findByPrincipalAndTypeAndTimestampBetween(
|
||||
username, eventType, start, end, pageable);
|
||||
} else if (eventType != null && username != null) {
|
||||
events = auditRepository.findByPrincipalAndType(username, eventType, pageable);
|
||||
} else if (eventType != null && startDate != null && endDate != null) {
|
||||
Instant start = startDate.atStartOfDay(ZoneId.systemDefault()).toInstant();
|
||||
Instant end = endDate.plusDays(1).atStartOfDay(ZoneId.systemDefault()).toInstant();
|
||||
events = auditRepository.findByTypeAndTimestampBetween(eventType, start, end, pageable);
|
||||
} else if (username != null && startDate != null && endDate != null) {
|
||||
Instant start = startDate.atStartOfDay(ZoneId.systemDefault()).toInstant();
|
||||
Instant end = endDate.plusDays(1).atStartOfDay(ZoneId.systemDefault()).toInstant();
|
||||
auditRepository.findByTypeInAndPrincipalInAndTimestampBetween(
|
||||
eventTypeList, usernameList, startInstant, endInstant, pageable);
|
||||
} else if (eventTypeList != null && usernameList != null) {
|
||||
events =
|
||||
auditRepository.findByPrincipalAndTimestampBetween(
|
||||
username, start, end, pageable);
|
||||
} else if (startDate != null && endDate != null) {
|
||||
Instant start = startDate.atStartOfDay(ZoneId.systemDefault()).toInstant();
|
||||
Instant end = endDate.plusDays(1).atStartOfDay(ZoneId.systemDefault()).toInstant();
|
||||
events = auditRepository.findByTimestampBetween(start, end, pageable);
|
||||
} else if (eventType != null) {
|
||||
events = auditRepository.findByType(eventType, pageable);
|
||||
} else if (username != null) {
|
||||
events = auditRepository.findByPrincipal(username, pageable);
|
||||
auditRepository.findByTypeInAndPrincipalIn(
|
||||
eventTypeList, usernameList, pageable);
|
||||
} else if (eventTypeList != null && startInstant != null && endInstant != null) {
|
||||
events =
|
||||
auditRepository.findByTypeInAndTimestampBetween(
|
||||
eventTypeList, startInstant, endInstant, pageable);
|
||||
} else if (usernameList != null && startInstant != null && endInstant != null) {
|
||||
events =
|
||||
auditRepository.findByPrincipalInAndTimestampBetween(
|
||||
usernameList, startInstant, endInstant, pageable);
|
||||
} else if (startInstant != null && endInstant != null) {
|
||||
events = auditRepository.findByTimestampBetween(startInstant, endInstant, pageable);
|
||||
} else if (eventTypeList != null) {
|
||||
events = auditRepository.findByTypeIn(eventTypeList, pageable);
|
||||
} else if (usernameList != null) {
|
||||
events = auditRepository.findByPrincipalIn(usernameList, pageable);
|
||||
} else {
|
||||
events = auditRepository.findAll(pageable);
|
||||
}
|
||||
@@ -258,11 +273,253 @@ public class AuditRestController {
|
||||
}
|
||||
|
||||
/**
|
||||
* Export audit data in CSV or JSON format. Maps to frontend's exportData() call.
|
||||
* Get audit statistics for KPI dashboard. Includes success rates, latency metrics, and top
|
||||
* items.
|
||||
*
|
||||
* @param period Time period for statistics (day/week/month)
|
||||
* @return Audit statistics data for dashboard KPI cards and enhanced charts
|
||||
*/
|
||||
@GetMapping("/audit-stats")
|
||||
public ResponseEntity<AuditStatsData> getAuditStats(
|
||||
@RequestParam(value = "period", defaultValue = "week") String period) {
|
||||
|
||||
// Calculate days based on period
|
||||
int days;
|
||||
switch (period.toLowerCase()) {
|
||||
case "day":
|
||||
days = 1;
|
||||
break;
|
||||
case "month":
|
||||
days = 30;
|
||||
break;
|
||||
case "week":
|
||||
default:
|
||||
days = 7;
|
||||
break;
|
||||
}
|
||||
|
||||
// Get events from the specified period and previous period
|
||||
Instant now = Instant.now();
|
||||
Instant start = now.minus(java.time.Duration.ofDays(days));
|
||||
Instant prevStart = start.minus(java.time.Duration.ofDays(days));
|
||||
|
||||
List<PersistentAuditEvent> currentEvents = auditRepository.findByTimestampAfter(start);
|
||||
List<PersistentAuditEvent> prevEvents =
|
||||
auditRepository.findAllByTimestampBetweenForExport(prevStart, start);
|
||||
|
||||
// Compute metrics for current period
|
||||
AuditMetrics currentMetrics = computeMetrics(currentEvents);
|
||||
AuditMetrics prevMetrics = computeMetrics(prevEvents);
|
||||
|
||||
// Get hourly distribution using DB aggregation
|
||||
List<Object[]> hourlyData = auditRepository.histogramByHourBetween(start, now);
|
||||
Map<String, Long> hourlyDistribution = new TreeMap<>();
|
||||
for (int h = 0; h < 24; h++) {
|
||||
hourlyDistribution.put(String.format("%02d", h), 0L);
|
||||
}
|
||||
for (Object[] row : hourlyData) {
|
||||
int hour = ((Number) row[0]).intValue();
|
||||
long count = ((Number) row[1]).longValue();
|
||||
hourlyDistribution.put(String.format("%02d", hour), count);
|
||||
}
|
||||
|
||||
return ResponseEntity.ok(
|
||||
AuditStatsData.builder()
|
||||
.totalEvents(currentMetrics.totalEvents)
|
||||
.prevTotalEvents(prevMetrics.totalEvents)
|
||||
.uniqueUsers(currentMetrics.uniqueUsers)
|
||||
.prevUniqueUsers(prevMetrics.uniqueUsers)
|
||||
.successRate(currentMetrics.successRate)
|
||||
.prevSuccessRate(prevMetrics.successRate)
|
||||
.avgLatencyMs(currentMetrics.avgLatencyMs)
|
||||
.prevAvgLatencyMs(prevMetrics.avgLatencyMs)
|
||||
.errorCount(currentMetrics.errorCount)
|
||||
.topEventType(currentMetrics.topEventType)
|
||||
.topUser(currentMetrics.topUser)
|
||||
.eventsByType(currentMetrics.eventsByType)
|
||||
.eventsByUser(currentMetrics.eventsByUser)
|
||||
.topTools(currentMetrics.topTools)
|
||||
.hourlyDistribution(hourlyDistribution)
|
||||
.build());
|
||||
}
|
||||
|
||||
/** Compute metrics from a list of audit events. */
|
||||
private AuditMetrics computeMetrics(List<PersistentAuditEvent> events) {
|
||||
if (events.isEmpty()) {
|
||||
return AuditMetrics.builder().build();
|
||||
}
|
||||
|
||||
// Count by type
|
||||
Map<String, Long> eventsByType =
|
||||
events.stream()
|
||||
.collect(
|
||||
Collectors.groupingBy(
|
||||
PersistentAuditEvent::getType, Collectors.counting()));
|
||||
|
||||
// Count by principal (user)
|
||||
Map<String, Long> eventsByUser =
|
||||
events.stream()
|
||||
.collect(
|
||||
Collectors.groupingBy(
|
||||
PersistentAuditEvent::getPrincipal, Collectors.counting()));
|
||||
|
||||
// Parse JSON data once for success rate, latency, tool extraction, and error counting
|
||||
long successCount = 0;
|
||||
long failureCount = 0;
|
||||
long errorCount = 0;
|
||||
long totalLatencyMs = 0;
|
||||
long latencyCount = 0;
|
||||
Map<String, Long> topTools = new HashMap<>();
|
||||
|
||||
for (PersistentAuditEvent event : events) {
|
||||
if (event.getData() != null) {
|
||||
try {
|
||||
@SuppressWarnings("unchecked")
|
||||
Map<String, Object> data = objectMapper.readValue(event.getData(), Map.class);
|
||||
|
||||
// Track success/failure (safe type conversion)
|
||||
// Check both "status" (current) and "outcome" (legacy) for compatibility
|
||||
Object statusObj = data.get("status");
|
||||
if (statusObj == null) {
|
||||
statusObj = data.get("outcome");
|
||||
}
|
||||
String status = null;
|
||||
if (statusObj instanceof String) {
|
||||
status = (String) statusObj;
|
||||
} else if (statusObj != null) {
|
||||
status = String.valueOf(statusObj);
|
||||
}
|
||||
if ("success".equals(status)) {
|
||||
successCount++;
|
||||
} else if ("failure".equals(status)) {
|
||||
failureCount++;
|
||||
errorCount++;
|
||||
} else {
|
||||
// Check statusCode for error counting (when status is not explicit failure)
|
||||
Object statusCode = data.get("statusCode");
|
||||
if (statusCode != null) {
|
||||
try {
|
||||
int statusCodeVal;
|
||||
if (statusCode instanceof Number) {
|
||||
statusCodeVal = ((Number) statusCode).intValue();
|
||||
} else if (statusCode instanceof String) {
|
||||
statusCodeVal = Integer.parseInt((String) statusCode);
|
||||
} else {
|
||||
statusCodeVal = 0;
|
||||
}
|
||||
if (statusCodeVal >= 400) {
|
||||
errorCount++;
|
||||
}
|
||||
} catch (NumberFormatException e) {
|
||||
log.trace("Failed to parse statusCode value: {}", statusCode);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Track latency (safe conversion to handle strings/numbers)
|
||||
Object latency = data.get("latencyMs");
|
||||
if (latency != null) {
|
||||
try {
|
||||
long latencyVal;
|
||||
if (latency instanceof Number) {
|
||||
latencyVal = ((Number) latency).longValue();
|
||||
} else if (latency instanceof String) {
|
||||
latencyVal = Long.parseLong((String) latency);
|
||||
} else {
|
||||
latencyVal = 0;
|
||||
}
|
||||
totalLatencyMs += latencyVal;
|
||||
latencyCount++;
|
||||
} catch (NumberFormatException e) {
|
||||
log.trace("Failed to parse latency value: {}", latency);
|
||||
}
|
||||
}
|
||||
|
||||
// Extract tool from path (safe type conversion)
|
||||
Object pathObj = data.get("path");
|
||||
String path = null;
|
||||
if (pathObj instanceof String) {
|
||||
path = (String) pathObj;
|
||||
} else if (pathObj != null) {
|
||||
path = String.valueOf(pathObj);
|
||||
}
|
||||
if (path != null && !path.isEmpty()) {
|
||||
String[] parts = path.split("/");
|
||||
if (parts.length > 0) {
|
||||
String tool = parts[parts.length - 1];
|
||||
if (!tool.isEmpty()) {
|
||||
topTools.put(tool, topTools.getOrDefault(tool, 0L) + 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (JacksonException e) {
|
||||
log.trace("Failed to parse audit event data: {}", event.getData());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Calculate success rate
|
||||
double successRate = 0;
|
||||
long totalWithOutcome = successCount + failureCount;
|
||||
if (totalWithOutcome > 0) {
|
||||
successRate = (successCount * 100.0) / totalWithOutcome;
|
||||
}
|
||||
|
||||
// Calculate average latency
|
||||
double avgLatencyMs = 0;
|
||||
if (latencyCount > 0) {
|
||||
avgLatencyMs = totalLatencyMs / (double) latencyCount;
|
||||
}
|
||||
|
||||
// Get top event type
|
||||
String topEventType =
|
||||
eventsByType.entrySet().stream()
|
||||
.max((e1, e2) -> Long.compare(e1.getValue(), e2.getValue()))
|
||||
.map(Map.Entry::getKey)
|
||||
.orElse("");
|
||||
|
||||
// Get top user
|
||||
String topUser =
|
||||
eventsByUser.entrySet().stream()
|
||||
.max((e1, e2) -> Long.compare(e1.getValue(), e2.getValue()))
|
||||
.map(Map.Entry::getKey)
|
||||
.orElse("");
|
||||
|
||||
// Sort and limit top tools to 10
|
||||
Map<String, Long> topToolsSorted =
|
||||
topTools.entrySet().stream()
|
||||
.sorted((e1, e2) -> Long.compare(e2.getValue(), e1.getValue()))
|
||||
.limit(10)
|
||||
.collect(
|
||||
Collectors.toMap(
|
||||
Map.Entry::getKey,
|
||||
Map.Entry::getValue,
|
||||
(e1, e2) -> e1,
|
||||
LinkedHashMap::new));
|
||||
|
||||
return AuditMetrics.builder()
|
||||
.totalEvents(events.size())
|
||||
.uniqueUsers((int) eventsByUser.size())
|
||||
.successRate(successRate)
|
||||
.avgLatencyMs(avgLatencyMs)
|
||||
.errorCount(errorCount)
|
||||
.topEventType(topEventType)
|
||||
.topUser(topUser)
|
||||
.eventsByType(eventsByType)
|
||||
.eventsByUser(eventsByUser)
|
||||
.topTools(topToolsSorted)
|
||||
.build();
|
||||
}
|
||||
|
||||
/**
|
||||
* Export audit data in CSV or JSON format. Maps to frontend's exportData() call. Supports both
|
||||
* single values and multi-select arrays for eventType and username.
|
||||
*
|
||||
* @param format Export format (csv or json)
|
||||
* @param eventType Filter by event type
|
||||
* @param username Filter by username
|
||||
* @param fields Comma-separated list of fields to include (e.g.,
|
||||
* "date,username,tool,documentName,author,fileHash")
|
||||
* @param eventTypes Filter by event type(s) - can be single value or array
|
||||
* @param usernames Filter by username(s) - can be single value or array
|
||||
* @param startDate Filter start date
|
||||
* @param endDate Filter end date
|
||||
* @return File download response
|
||||
@@ -270,8 +527,9 @@ public class AuditRestController {
|
||||
@GetMapping("/audit-export")
|
||||
public ResponseEntity<byte[]> exportAuditData(
|
||||
@RequestParam(value = "format", defaultValue = "csv") String format,
|
||||
@RequestParam(value = "eventType", required = false) String eventType,
|
||||
@RequestParam(value = "username", required = false) String username,
|
||||
@RequestParam(value = "fields", required = false) String fields,
|
||||
@RequestParam(value = "eventType", required = false) String[] eventTypes,
|
||||
@RequestParam(value = "username", required = false) String[] usernames,
|
||||
@RequestParam(value = "startDate", required = false)
|
||||
@DateTimeFormat(iso = DateTimeFormat.ISO.DATE)
|
||||
LocalDate startDate,
|
||||
@@ -282,34 +540,44 @@ public class AuditRestController {
|
||||
// Get data with same filtering as getAuditEvents
|
||||
List<PersistentAuditEvent> events;
|
||||
|
||||
if (eventType != null && username != null && startDate != null && endDate != null) {
|
||||
Instant start = startDate.atStartOfDay(ZoneId.systemDefault()).toInstant();
|
||||
Instant end = endDate.plusDays(1).atStartOfDay(ZoneId.systemDefault()).toInstant();
|
||||
// Convert arrays to lists
|
||||
List<String> eventTypeList =
|
||||
(eventTypes != null && eventTypes.length > 0) ? Arrays.asList(eventTypes) : null;
|
||||
List<String> usernameList =
|
||||
(usernames != null && usernames.length > 0) ? Arrays.asList(usernames) : null;
|
||||
|
||||
Instant startInstant = null;
|
||||
Instant endInstant = null;
|
||||
if (startDate != null && endDate != null) {
|
||||
startInstant = startDate.atStartOfDay(ZoneId.systemDefault()).toInstant();
|
||||
endInstant = endDate.plusDays(1).atStartOfDay(ZoneId.systemDefault()).toInstant();
|
||||
}
|
||||
|
||||
if (eventTypeList != null
|
||||
&& usernameList != null
|
||||
&& startInstant != null
|
||||
&& endInstant != null) {
|
||||
events =
|
||||
auditRepository.findAllByPrincipalAndTypeAndTimestampBetweenForExport(
|
||||
username, eventType, start, end);
|
||||
} else if (eventType != null && username != null) {
|
||||
events = auditRepository.findAllByPrincipalAndTypeForExport(username, eventType);
|
||||
} else if (eventType != null && startDate != null && endDate != null) {
|
||||
Instant start = startDate.atStartOfDay(ZoneId.systemDefault()).toInstant();
|
||||
Instant end = endDate.plusDays(1).atStartOfDay(ZoneId.systemDefault()).toInstant();
|
||||
auditRepository.findByTypeInAndPrincipalInAndTimestampBetweenForExport(
|
||||
eventTypeList, usernameList, startInstant, endInstant);
|
||||
} else if (eventTypeList != null && usernameList != null) {
|
||||
events =
|
||||
auditRepository.findAllByTypeAndTimestampBetweenForExport(
|
||||
eventType, start, end);
|
||||
} else if (username != null && startDate != null && endDate != null) {
|
||||
Instant start = startDate.atStartOfDay(ZoneId.systemDefault()).toInstant();
|
||||
Instant end = endDate.plusDays(1).atStartOfDay(ZoneId.systemDefault()).toInstant();
|
||||
auditRepository.findByTypeInAndPrincipalInForExport(
|
||||
eventTypeList, usernameList);
|
||||
} else if (eventTypeList != null && startInstant != null && endInstant != null) {
|
||||
events =
|
||||
auditRepository.findAllByPrincipalAndTimestampBetweenForExport(
|
||||
username, start, end);
|
||||
} else if (startDate != null && endDate != null) {
|
||||
Instant start = startDate.atStartOfDay(ZoneId.systemDefault()).toInstant();
|
||||
Instant end = endDate.plusDays(1).atStartOfDay(ZoneId.systemDefault()).toInstant();
|
||||
events = auditRepository.findAllByTimestampBetweenForExport(start, end);
|
||||
} else if (eventType != null) {
|
||||
events = auditRepository.findByTypeForExport(eventType);
|
||||
} else if (username != null) {
|
||||
events = auditRepository.findAllByPrincipalForExport(username);
|
||||
auditRepository.findByTypeInAndTimestampBetweenForExport(
|
||||
eventTypeList, startInstant, endInstant);
|
||||
} else if (usernameList != null && startInstant != null && endInstant != null) {
|
||||
events =
|
||||
auditRepository.findByPrincipalInAndTimestampBetweenForExport(
|
||||
usernameList, startInstant, endInstant);
|
||||
} else if (startInstant != null && endInstant != null) {
|
||||
events = auditRepository.findAllByTimestampBetweenForExport(startInstant, endInstant);
|
||||
} else if (eventTypeList != null) {
|
||||
events = auditRepository.findByTypeInForExport(eventTypeList);
|
||||
} else if (usernameList != null) {
|
||||
events = auditRepository.findByPrincipalInForExport(usernameList);
|
||||
} else {
|
||||
events = auditRepository.findAll();
|
||||
}
|
||||
@@ -318,7 +586,7 @@ public class AuditRestController {
|
||||
if ("json".equalsIgnoreCase(format)) {
|
||||
return exportAsJson(events);
|
||||
} else {
|
||||
return exportAsCsv(events);
|
||||
return exportAsCsv(events, fields);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -338,17 +606,89 @@ public class AuditRestController {
|
||||
}
|
||||
}
|
||||
|
||||
// Extract IP address (check both clientIp and __ipAddress for async/audited events)
|
||||
String ipAddress = "";
|
||||
Object ipObj = details.get("clientIp");
|
||||
if (ipObj != null) {
|
||||
ipAddress = String.valueOf(ipObj);
|
||||
} else {
|
||||
ipObj = details.get("__ipAddress");
|
||||
if (ipObj != null) {
|
||||
ipAddress = String.valueOf(ipObj);
|
||||
}
|
||||
}
|
||||
|
||||
return AuditEventDto.builder()
|
||||
.id(String.valueOf(event.getId()))
|
||||
.timestamp(event.getTimestamp().toString())
|
||||
.eventType(event.getType())
|
||||
.username(event.getPrincipal())
|
||||
.ipAddress((String) details.getOrDefault("ipAddress", "")) // Extract if available
|
||||
.ipAddress(ipAddress)
|
||||
.details(details)
|
||||
.build();
|
||||
}
|
||||
|
||||
private ResponseEntity<byte[]> exportAsCsv(List<PersistentAuditEvent> events) {
|
||||
private ResponseEntity<byte[]> exportAsCsv(List<PersistentAuditEvent> events, String fields) {
|
||||
// Parse selected fields (comma-separated:
|
||||
// date,username,tool,documentName,author,fileHash,ipAddress,etc)
|
||||
Set<String> selectedFields = new HashSet<>();
|
||||
if (fields != null && !fields.trim().isEmpty()) {
|
||||
String[] fieldArray = fields.split(",");
|
||||
for (String field : fieldArray) {
|
||||
selectedFields.add(field.trim().toLowerCase());
|
||||
}
|
||||
}
|
||||
|
||||
// If no fields specified, use default technical export
|
||||
if (selectedFields.isEmpty()) {
|
||||
return exportAsDefaultCsv(events);
|
||||
}
|
||||
|
||||
StringBuilder csv = new StringBuilder();
|
||||
|
||||
// Build header based on selected fields
|
||||
List<String> headerOrder = new ArrayList<>();
|
||||
if (selectedFields.contains("date")) headerOrder.add("date");
|
||||
if (selectedFields.contains("username")) headerOrder.add("username");
|
||||
if (selectedFields.contains("ipaddress")) headerOrder.add("ipaddress");
|
||||
if (selectedFields.contains("tool")) headerOrder.add("tool");
|
||||
if (selectedFields.contains("documentname")) headerOrder.add("documentname");
|
||||
if (selectedFields.contains("outcome")) headerOrder.add("outcome");
|
||||
if (selectedFields.contains("author")) headerOrder.add("author");
|
||||
if (selectedFields.contains("filehash")) headerOrder.add("filehash");
|
||||
if (selectedFields.contains("operationresults")) headerOrder.add("operationresults");
|
||||
if (selectedFields.contains("eventtype")) headerOrder.add("eventtype");
|
||||
|
||||
// Write header
|
||||
for (int i = 0; i < headerOrder.size(); i++) {
|
||||
csv.append(capitalizeHeader(headerOrder.get(i)));
|
||||
if (i < headerOrder.size() - 1) csv.append(",");
|
||||
}
|
||||
csv.append("\n");
|
||||
|
||||
DateTimeFormatter formatter = DateTimeFormatter.ISO_INSTANT;
|
||||
|
||||
// Write data rows
|
||||
for (PersistentAuditEvent event : events) {
|
||||
Map<String, String> rowData = extractEventData(event, formatter);
|
||||
|
||||
for (int i = 0; i < headerOrder.size(); i++) {
|
||||
csv.append(escapeCSV(rowData.getOrDefault(headerOrder.get(i), "")));
|
||||
if (i < headerOrder.size() - 1) csv.append(",");
|
||||
}
|
||||
csv.append("\n");
|
||||
}
|
||||
|
||||
byte[] csvBytes = csv.toString().getBytes(StandardCharsets.UTF_8);
|
||||
HttpHeaders headers = new HttpHeaders();
|
||||
headers.setContentType(MediaType.parseMediaType("text/csv;charset=UTF-8"));
|
||||
headers.setContentDispositionFormData(
|
||||
"attachment", "audit_export_" + System.currentTimeMillis() + ".csv");
|
||||
|
||||
return ResponseEntity.ok().headers(headers).body(csvBytes);
|
||||
}
|
||||
|
||||
private ResponseEntity<byte[]> exportAsDefaultCsv(List<PersistentAuditEvent> events) {
|
||||
StringBuilder csv = new StringBuilder();
|
||||
csv.append("ID,Principal,Type,Timestamp,Data\n");
|
||||
|
||||
@@ -362,15 +702,102 @@ public class AuditRestController {
|
||||
csv.append(escapeCSV(event.getData())).append("\n");
|
||||
}
|
||||
|
||||
byte[] csvBytes = csv.toString().getBytes();
|
||||
|
||||
byte[] csvBytes = csv.toString().getBytes(StandardCharsets.UTF_8);
|
||||
HttpHeaders headers = new HttpHeaders();
|
||||
headers.setContentType(MediaType.APPLICATION_OCTET_STREAM);
|
||||
headers.setContentType(MediaType.parseMediaType("text/csv;charset=UTF-8"));
|
||||
headers.setContentDispositionFormData("attachment", "audit_export.csv");
|
||||
|
||||
return ResponseEntity.ok().headers(headers).body(csvBytes);
|
||||
}
|
||||
|
||||
private Map<String, String> extractEventData(
|
||||
PersistentAuditEvent event, DateTimeFormatter formatter) {
|
||||
Map<String, String> data = new HashMap<>();
|
||||
|
||||
data.put("date", formatter.format(event.getTimestamp()));
|
||||
data.put("username", event.getPrincipal());
|
||||
data.put("eventtype", event.getType());
|
||||
data.put("ipaddress", "");
|
||||
data.put("tool", "");
|
||||
data.put("documentname", "");
|
||||
data.put("outcome", "");
|
||||
data.put("author", "");
|
||||
data.put("filehash", "");
|
||||
data.put("operationresults", "");
|
||||
|
||||
if (event.getData() != null) {
|
||||
try {
|
||||
@SuppressWarnings("unchecked")
|
||||
Map<String, Object> eventData = objectMapper.readValue(event.getData(), Map.class);
|
||||
|
||||
// Extract IP address (check both clientIp and __ipAddress)
|
||||
String ipAddress = "";
|
||||
if (eventData.containsKey("clientIp")) {
|
||||
ipAddress = String.valueOf(eventData.getOrDefault("clientIp", ""));
|
||||
} else if (eventData.containsKey("__ipAddress")) {
|
||||
ipAddress = String.valueOf(eventData.getOrDefault("__ipAddress", ""));
|
||||
}
|
||||
if (!ipAddress.isEmpty()) {
|
||||
data.put("ipaddress", ipAddress);
|
||||
}
|
||||
|
||||
// Extract outcome (success/failure), supporting legacy "status" key
|
||||
if (eventData.containsKey("outcome")) {
|
||||
data.put("outcome", String.valueOf(eventData.getOrDefault("outcome", "")));
|
||||
} else if (eventData.containsKey("status")) {
|
||||
data.put("outcome", String.valueOf(eventData.getOrDefault("status", "")));
|
||||
}
|
||||
|
||||
// Extract operation result if present
|
||||
if (eventData.containsKey("result")) {
|
||||
data.put(
|
||||
"operationresults",
|
||||
String.valueOf(eventData.getOrDefault("result", "")));
|
||||
}
|
||||
|
||||
// Extract tool from path
|
||||
if (eventData.containsKey("path")) {
|
||||
String path = (String) eventData.get("path");
|
||||
if (path != null) {
|
||||
String[] parts = path.split("/");
|
||||
data.put("tool", parts.length > 0 ? parts[parts.length - 1] : "");
|
||||
}
|
||||
}
|
||||
|
||||
// Extract file information
|
||||
@SuppressWarnings("unchecked")
|
||||
List<Map<String, Object>> files =
|
||||
(List<Map<String, Object>>) eventData.get("files");
|
||||
if (files != null && !files.isEmpty()) {
|
||||
Map<String, Object> firstFile = files.get(0);
|
||||
data.put("documentname", String.valueOf(firstFile.getOrDefault("name", "")));
|
||||
data.put("author", String.valueOf(firstFile.getOrDefault("pdfAuthor", "")));
|
||||
data.put("filehash", String.valueOf(firstFile.getOrDefault("fileHash", "")));
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.trace("Failed to parse audit event data: {}", event.getData());
|
||||
}
|
||||
}
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
private String capitalizeHeader(String field) {
|
||||
return switch (field.toLowerCase()) {
|
||||
case "date" -> "Date";
|
||||
case "username" -> "Username";
|
||||
case "ipaddress" -> "IP Address";
|
||||
case "tool" -> "Tool";
|
||||
case "documentname" -> "Document Name";
|
||||
case "outcome" -> "Outcome";
|
||||
case "author" -> "Author";
|
||||
case "filehash" -> "File Hash";
|
||||
case "operationresults" -> "Operation Results";
|
||||
case "eventtype" -> "Event Type";
|
||||
default -> field;
|
||||
};
|
||||
}
|
||||
|
||||
private ResponseEntity<byte[]> exportAsJson(List<PersistentAuditEvent> events) {
|
||||
try {
|
||||
byte[] jsonBytes = objectMapper.writeValueAsBytes(events);
|
||||
@@ -431,4 +858,60 @@ public class AuditRestController {
|
||||
private List<String> labels;
|
||||
private List<Integer> values;
|
||||
}
|
||||
|
||||
@lombok.Data
|
||||
@lombok.Builder
|
||||
public static class AuditStatsData {
|
||||
private long totalEvents;
|
||||
private long prevTotalEvents;
|
||||
private int uniqueUsers;
|
||||
private int prevUniqueUsers;
|
||||
private double successRate;
|
||||
private double prevSuccessRate;
|
||||
private double avgLatencyMs;
|
||||
private double prevAvgLatencyMs;
|
||||
private long errorCount;
|
||||
private String topEventType;
|
||||
private String topUser;
|
||||
private Map<String, Long> eventsByType;
|
||||
private Map<String, Long> eventsByUser;
|
||||
private Map<String, Long> topTools;
|
||||
private Map<String, Long> hourlyDistribution;
|
||||
}
|
||||
|
||||
@lombok.Data
|
||||
@lombok.Builder
|
||||
public static class AuditMetrics {
|
||||
private long totalEvents;
|
||||
private int uniqueUsers;
|
||||
private double successRate;
|
||||
private double avgLatencyMs;
|
||||
private long errorCount;
|
||||
private String topEventType;
|
||||
private String topUser;
|
||||
private Map<String, Long> eventsByType;
|
||||
private Map<String, Long> eventsByUser;
|
||||
private Map<String, Long> topTools;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all audit data from the database. This is an irreversible operation. Requires ADMIN
|
||||
* role.
|
||||
*
|
||||
* @return Success response
|
||||
*/
|
||||
@PostMapping("/audit-clear-all")
|
||||
public ResponseEntity<?> clearAllAuditData() {
|
||||
try {
|
||||
// Delete all audit events
|
||||
auditRepository.deleteAll();
|
||||
log.warn("All audit data has been cleared by admin user");
|
||||
return ResponseEntity.ok()
|
||||
.body(Map.of("message", "All audit data has been cleared successfully"));
|
||||
} catch (Exception e) {
|
||||
log.error("Error clearing audit data", e);
|
||||
return ResponseEntity.internalServerError()
|
||||
.body("Failed to clear audit data: " + e.getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+11
@@ -127,6 +127,13 @@ public class ProprietaryUIDataController {
|
||||
data.setRetentionDays(auditConfig.getRetentionDays());
|
||||
data.setAuditLevels(AuditLevel.values());
|
||||
data.setAuditEventTypes(AuditEventType.values());
|
||||
// Metadata capture settings (independent flags)
|
||||
data.setCaptureFileHash(auditConfig.isCaptureFileHash());
|
||||
data.setCapturePdfAuthor(auditConfig.isCapturePdfAuthor());
|
||||
data.setCaptureOperationResults(auditConfig.isCaptureOperationResults());
|
||||
// pdfMetadataEnabled: true if any metadata flag is enabled (file hash or PDF author)
|
||||
data.setPdfMetadataEnabled(
|
||||
auditConfig.isCaptureFileHash() || auditConfig.isCapturePdfAuthor());
|
||||
|
||||
return ResponseEntity.ok(data);
|
||||
}
|
||||
@@ -560,6 +567,10 @@ public class ProprietaryUIDataController {
|
||||
private int retentionDays;
|
||||
private AuditLevel[] auditLevels;
|
||||
private AuditEventType[] auditEventTypes;
|
||||
private boolean pdfMetadataEnabled;
|
||||
private boolean captureFileHash;
|
||||
private boolean capturePdfAuthor;
|
||||
private boolean captureOperationResults;
|
||||
}
|
||||
|
||||
@Data
|
||||
|
||||
+30
-54
@@ -1,5 +1,7 @@
|
||||
package stirling.software.proprietary.controller.api;
|
||||
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.util.*;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
@@ -32,34 +34,32 @@ public class UsageRestController {
|
||||
private final ObjectMapper objectMapper;
|
||||
|
||||
/**
|
||||
* Get endpoint statistics derived from audit events. This endpoint analyzes HTTP_REQUEST audit
|
||||
* events to generate usage statistics.
|
||||
* Get endpoint statistics derived from audit events. This endpoint analyzes audit events
|
||||
* filtered by type to generate usage statistics.
|
||||
*
|
||||
* @param limit Optional limit on number of endpoints to return
|
||||
* @param dataType Type of data to include: "all" (default), "api" (API endpoints excluding
|
||||
* auth), or "ui" (non-API endpoints)
|
||||
* @param dataType Type of data to include: "all" (default), "api" (operational endpoints), or
|
||||
* "ui" (UI data endpoints)
|
||||
* @param days Lookback window in days (default 30, clamped to 1-365)
|
||||
* @return Endpoint statistics response
|
||||
*/
|
||||
@GetMapping("/usage-endpoint-statistics")
|
||||
public ResponseEntity<EndpointStatisticsResponse> getEndpointStatistics(
|
||||
@RequestParam(value = "limit", required = false) Integer limit,
|
||||
@RequestParam(value = "dataType", defaultValue = "all") String dataType) {
|
||||
@RequestParam(value = "dataType", defaultValue = "all") String dataType,
|
||||
@RequestParam(value = "days", defaultValue = "30") Integer days) {
|
||||
|
||||
// Get all HTTP_REQUEST audit events
|
||||
List<PersistentAuditEvent> httpEvents =
|
||||
auditRepository.findByTypeForExport(AuditEventType.HTTP_REQUEST.name());
|
||||
int lookbackDays = Math.max(1, Math.min(days, 365));
|
||||
|
||||
// Get audit events filtered by type
|
||||
List<PersistentAuditEvent> events = getEventsByDataType(dataType, lookbackDays);
|
||||
|
||||
// Count visits per endpoint
|
||||
Map<String, Long> endpointCounts = new HashMap<>();
|
||||
|
||||
for (PersistentAuditEvent event : httpEvents) {
|
||||
for (PersistentAuditEvent event : events) {
|
||||
String endpoint = extractEndpointFromAuditData(event.getData());
|
||||
if (endpoint != null) {
|
||||
// Apply data type filter
|
||||
if (!shouldIncludeEndpoint(endpoint, dataType)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
endpointCounts.merge(endpoint, 1L, Long::sum);
|
||||
}
|
||||
}
|
||||
@@ -168,52 +168,28 @@ public class UsageRestController {
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine if an endpoint should be included based on the data type filter.
|
||||
* Get audit events filtered by data type. UI = UI_DATA events. API = everything except UI_DATA.
|
||||
*
|
||||
* @param endpoint The endpoint path to check
|
||||
* @param dataType The filter type: "all", "api", or "ui"
|
||||
* @return true if the endpoint should be included, false otherwise
|
||||
* @param dataType "all", "api" (not UI_DATA), or "ui" (UI_DATA only)
|
||||
* @param days lookback window in days
|
||||
* @return List of audit events matching the data type filter
|
||||
*/
|
||||
private boolean shouldIncludeEndpoint(String endpoint, String dataType) {
|
||||
private List<PersistentAuditEvent> getEventsByDataType(String dataType, int days) {
|
||||
Instant start = Instant.now().minus(Duration.ofDays(days));
|
||||
|
||||
if ("all".equalsIgnoreCase(dataType)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
boolean isApiEndpoint = isApiEndpoint(endpoint);
|
||||
|
||||
if ("api".equalsIgnoreCase(dataType)) {
|
||||
return isApiEndpoint;
|
||||
return auditRepository.findByTimestampAfter(start);
|
||||
} else if ("ui".equalsIgnoreCase(dataType)) {
|
||||
return !isApiEndpoint;
|
||||
// UI data endpoints only
|
||||
return auditRepository.findByTypeAndTimestampAfterForExport(
|
||||
AuditEventType.UI_DATA.name(), start);
|
||||
} else if ("api".equalsIgnoreCase(dataType)) {
|
||||
// API = everything except UI_DATA (queried at DB level, not filtered in-memory)
|
||||
return auditRepository.findAllExceptTypeAndTimestampAfterForExport(
|
||||
AuditEventType.UI_DATA.name(), start);
|
||||
}
|
||||
|
||||
// Default to including all if unrecognized type
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if an endpoint is an API endpoint. API endpoints match /api/v1/* pattern but exclude
|
||||
* /api/v1/auth/* paths.
|
||||
*
|
||||
* @param endpoint The endpoint path to check
|
||||
* @return true if this is an API endpoint (excluding auth endpoints), false otherwise
|
||||
*/
|
||||
private boolean isApiEndpoint(String endpoint) {
|
||||
if (endpoint == null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check if it starts with /api/v1/
|
||||
if (!endpoint.startsWith("/api/v1/")) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Exclude auth endpoints
|
||||
if (endpoint.startsWith("/api/v1/auth/")) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
return new ArrayList<>();
|
||||
}
|
||||
|
||||
// DTOs for response formatting
|
||||
|
||||
+88
@@ -68,6 +68,10 @@ public interface PersistentAuditEventRepository extends JpaRepository<Persistent
|
||||
@Query("SELECT e FROM PersistentAuditEvent e WHERE e.type = :type")
|
||||
List<PersistentAuditEvent> findByTypeForExport(@Param("type") String type);
|
||||
|
||||
@Query("SELECT e FROM PersistentAuditEvent e WHERE e.type = :type AND e.timestamp > :startDate")
|
||||
List<PersistentAuditEvent> findByTypeAndTimestampAfterForExport(
|
||||
@Param("type") String type, @Param("startDate") Instant startDate);
|
||||
|
||||
@Query("SELECT e FROM PersistentAuditEvent e WHERE e.timestamp BETWEEN :startDate AND :endDate")
|
||||
List<PersistentAuditEvent> findAllByTimestampBetweenForExport(
|
||||
@Param("startDate") Instant startDate, @Param("endDate") Instant endDate);
|
||||
@@ -171,4 +175,88 @@ public interface PersistentAuditEventRepository extends JpaRepository<Persistent
|
||||
Optional<PersistentAuditEvent> findTopByPrincipalOrderByTimestampDesc(String principal);
|
||||
|
||||
Optional<PersistentAuditEvent> findTopByTypeOrderByTimestampDesc(String type);
|
||||
|
||||
// Multi-value queries for filtering by multiple types and/or principals
|
||||
@Query("SELECT e FROM PersistentAuditEvent e WHERE e.type IN :types")
|
||||
Page<PersistentAuditEvent> findByTypeIn(@Param("types") List<String> types, Pageable pageable);
|
||||
|
||||
@Query("SELECT e FROM PersistentAuditEvent e WHERE e.principal IN :principals")
|
||||
Page<PersistentAuditEvent> findByPrincipalIn(
|
||||
@Param("principals") List<String> principals, Pageable pageable);
|
||||
|
||||
@Query(
|
||||
"SELECT e FROM PersistentAuditEvent e WHERE e.type IN :types AND e.timestamp BETWEEN :startDate AND :endDate")
|
||||
Page<PersistentAuditEvent> findByTypeInAndTimestampBetween(
|
||||
@Param("types") List<String> types,
|
||||
@Param("startDate") Instant startDate,
|
||||
@Param("endDate") Instant endDate,
|
||||
Pageable pageable);
|
||||
|
||||
@Query(
|
||||
"SELECT e FROM PersistentAuditEvent e WHERE e.principal IN :principals AND e.timestamp BETWEEN :startDate AND :endDate")
|
||||
Page<PersistentAuditEvent> findByPrincipalInAndTimestampBetween(
|
||||
@Param("principals") List<String> principals,
|
||||
@Param("startDate") Instant startDate,
|
||||
@Param("endDate") Instant endDate,
|
||||
Pageable pageable);
|
||||
|
||||
@Query(
|
||||
"SELECT e FROM PersistentAuditEvent e WHERE e.type IN :types AND e.principal IN :principals")
|
||||
Page<PersistentAuditEvent> findByTypeInAndPrincipalIn(
|
||||
@Param("types") List<String> types,
|
||||
@Param("principals") List<String> principals,
|
||||
Pageable pageable);
|
||||
|
||||
@Query(
|
||||
"SELECT e FROM PersistentAuditEvent e WHERE e.type IN :types AND e.principal IN :principals AND e.timestamp BETWEEN :startDate AND :endDate")
|
||||
Page<PersistentAuditEvent> findByTypeInAndPrincipalInAndTimestampBetween(
|
||||
@Param("types") List<String> types,
|
||||
@Param("principals") List<String> principals,
|
||||
@Param("startDate") Instant startDate,
|
||||
@Param("endDate") Instant endDate,
|
||||
Pageable pageable);
|
||||
|
||||
// Export versions (non-paged)
|
||||
@Query("SELECT e FROM PersistentAuditEvent e WHERE e.type IN :types")
|
||||
List<PersistentAuditEvent> findByTypeInForExport(@Param("types") List<String> types);
|
||||
|
||||
@Query("SELECT e FROM PersistentAuditEvent e WHERE e.principal IN :principals")
|
||||
List<PersistentAuditEvent> findByPrincipalInForExport(
|
||||
@Param("principals") List<String> principals);
|
||||
|
||||
@Query(
|
||||
"SELECT e FROM PersistentAuditEvent e WHERE e.type IN :types AND e.timestamp BETWEEN :startDate AND :endDate")
|
||||
List<PersistentAuditEvent> findByTypeInAndTimestampBetweenForExport(
|
||||
@Param("types") List<String> types,
|
||||
@Param("startDate") Instant startDate,
|
||||
@Param("endDate") Instant endDate);
|
||||
|
||||
@Query(
|
||||
"SELECT e FROM PersistentAuditEvent e WHERE e.principal IN :principals AND e.timestamp BETWEEN :startDate AND :endDate")
|
||||
List<PersistentAuditEvent> findByPrincipalInAndTimestampBetweenForExport(
|
||||
@Param("principals") List<String> principals,
|
||||
@Param("startDate") Instant startDate,
|
||||
@Param("endDate") Instant endDate);
|
||||
|
||||
@Query(
|
||||
"SELECT e FROM PersistentAuditEvent e WHERE e.type IN :types AND e.principal IN :principals")
|
||||
List<PersistentAuditEvent> findByTypeInAndPrincipalInForExport(
|
||||
@Param("types") List<String> types, @Param("principals") List<String> principals);
|
||||
|
||||
@Query(
|
||||
"SELECT e FROM PersistentAuditEvent e WHERE e.type IN :types AND e.principal IN :principals AND e.timestamp BETWEEN :startDate AND :endDate")
|
||||
List<PersistentAuditEvent> findByTypeInAndPrincipalInAndTimestampBetweenForExport(
|
||||
@Param("types") List<String> types,
|
||||
@Param("principals") List<String> principals,
|
||||
@Param("startDate") Instant startDate,
|
||||
@Param("endDate") Instant endDate);
|
||||
|
||||
// Query events excluding a specific type (used for analytics where we want to exclude UI_DATA)
|
||||
@Query("SELECT e FROM PersistentAuditEvent e WHERE e.type != :excludeType")
|
||||
List<PersistentAuditEvent> findAllExceptTypeForExport(@Param("excludeType") String excludeType);
|
||||
|
||||
@Query(
|
||||
"SELECT e FROM PersistentAuditEvent e WHERE e.type != :excludeType AND e.timestamp > :startDate")
|
||||
List<PersistentAuditEvent> findAllExceptTypeAndTimestampAfterForExport(
|
||||
@Param("excludeType") String excludeType, @Param("startDate") Instant startDate);
|
||||
}
|
||||
|
||||
+28
-11
@@ -16,6 +16,7 @@ import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
import java.util.function.Supplier;
|
||||
|
||||
import org.slf4j.MDC;
|
||||
import org.springframework.context.MessageSource;
|
||||
import org.springframework.context.i18n.LocaleContextHolder;
|
||||
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
|
||||
@@ -522,19 +523,35 @@ public class UserService implements UserServiceInterface {
|
||||
|
||||
@Override
|
||||
public String getCurrentUsername() {
|
||||
Object principal = SecurityContextHolder.getContext().getAuthentication().getPrincipal();
|
||||
// Try SecurityContext first (normal request context)
|
||||
try {
|
||||
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
|
||||
if (auth != null) {
|
||||
Object principal = auth.getPrincipal();
|
||||
|
||||
if (principal instanceof UserDetails detailsUser) {
|
||||
return detailsUser.getUsername();
|
||||
} else if (principal instanceof User domainUser) {
|
||||
return domainUser.getUsername();
|
||||
} else if (principal instanceof OAuth2User oAuth2User) {
|
||||
return oAuth2User.getAttribute(oAuth2.getUseAsUsername());
|
||||
} else if (principal instanceof CustomSaml2AuthenticatedPrincipal saml2User) {
|
||||
return saml2User.name();
|
||||
} else if (principal instanceof String stringUser) {
|
||||
return stringUser;
|
||||
if (principal instanceof UserDetails detailsUser) {
|
||||
return detailsUser.getUsername();
|
||||
} else if (principal instanceof User domainUser) {
|
||||
return domainUser.getUsername();
|
||||
} else if (principal instanceof OAuth2User oAuth2User) {
|
||||
return oAuth2User.getAttribute(oAuth2.getUseAsUsername());
|
||||
} else if (principal instanceof CustomSaml2AuthenticatedPrincipal saml2User) {
|
||||
return saml2User.name();
|
||||
} else if (principal instanceof String stringUser) {
|
||||
return stringUser;
|
||||
}
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.trace("Error retrieving username from SecurityContext, falling back to MDC", e);
|
||||
}
|
||||
|
||||
// Fallback to MDC for async contexts (e.g., when called from async job threads)
|
||||
// ControllerAuditAspect captures principal in MDC and AutoJobAspect propagates it
|
||||
String mdcPrincipal = MDC.get("auditPrincipal");
|
||||
if (mdcPrincipal != null && !mdcPrincipal.isEmpty()) {
|
||||
return mdcPrincipal;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
+804
-5
@@ -1,23 +1,55 @@
|
||||
package stirling.software.proprietary.service;
|
||||
|
||||
import java.io.InputStream;
|
||||
import java.lang.reflect.Method;
|
||||
import java.security.DigestInputStream;
|
||||
import java.security.MessageDigest;
|
||||
import java.time.Instant;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Arrays;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
import java.util.stream.Collectors;
|
||||
import java.util.stream.IntStream;
|
||||
|
||||
import org.apache.commons.lang3.StringUtils;
|
||||
import org.apache.pdfbox.pdmodel.PDDocument;
|
||||
import org.apache.pdfbox.pdmodel.PDDocumentInformation;
|
||||
import org.aspectj.lang.ProceedingJoinPoint;
|
||||
import org.aspectj.lang.reflect.MethodSignature;
|
||||
import org.slf4j.MDC;
|
||||
import org.springframework.beans.factory.annotation.Qualifier;
|
||||
import org.springframework.boot.actuate.audit.AuditEvent;
|
||||
import org.springframework.boot.actuate.audit.AuditEventRepository;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.security.core.Authentication;
|
||||
import org.springframework.security.core.context.SecurityContextHolder;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.web.context.request.RequestContextHolder;
|
||||
import org.springframework.web.context.request.ServletRequestAttributes;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import jakarta.servlet.http.HttpServletResponse;
|
||||
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import stirling.software.common.model.api.PDFFile;
|
||||
import stirling.software.common.service.CustomPDFDocumentFactory;
|
||||
import stirling.software.common.util.RegexPatternUtils;
|
||||
import stirling.software.common.util.RequestUriUtils;
|
||||
import stirling.software.proprietary.audit.AuditEventType;
|
||||
import stirling.software.proprietary.audit.AuditLevel;
|
||||
import stirling.software.proprietary.audit.Audited;
|
||||
import stirling.software.proprietary.config.AuditConfigurationProperties;
|
||||
import stirling.software.proprietary.security.model.ApiKeyAuthenticationToken;
|
||||
import stirling.software.proprietary.security.service.JwtServiceInterface;
|
||||
|
||||
/**
|
||||
* Service for creating manual audit events throughout the application. This provides easy access to
|
||||
* audit functionality in any component.
|
||||
* Service for audit event creation, data collection, and persistence. Combines persistence logic
|
||||
* with data collection utilities for comprehensive audit trail management.
|
||||
*/
|
||||
@Slf4j
|
||||
@Service
|
||||
@@ -26,16 +58,24 @@ public class AuditService {
|
||||
private final AuditEventRepository repository;
|
||||
private final AuditConfigurationProperties auditConfig;
|
||||
private final boolean runningEE;
|
||||
private final CustomPDFDocumentFactory pdfDocumentFactory;
|
||||
private final JwtServiceInterface jwtService;
|
||||
|
||||
public AuditService(
|
||||
AuditEventRepository repository,
|
||||
AuditConfigurationProperties auditConfig,
|
||||
@Qualifier("runningEE") boolean runningEE) {
|
||||
@Qualifier("runningEE") boolean runningEE,
|
||||
CustomPDFDocumentFactory pdfDocumentFactory,
|
||||
JwtServiceInterface jwtService) {
|
||||
this.repository = repository;
|
||||
this.auditConfig = auditConfig;
|
||||
this.runningEE = runningEE;
|
||||
this.pdfDocumentFactory = pdfDocumentFactory;
|
||||
this.jwtService = jwtService;
|
||||
}
|
||||
|
||||
// ========== PERSISTENCE METHODS ==========
|
||||
|
||||
/**
|
||||
* Record an audit event for the current authenticated user with a specific audit level using
|
||||
* the standardized AuditEventType enum
|
||||
@@ -53,7 +93,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 +160,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 +211,758 @@ 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));
|
||||
}
|
||||
|
||||
// ========== DATA COLLECTION METHODS ==========
|
||||
|
||||
/**
|
||||
* Create a standard audit data map with common attributes based on the current audit level
|
||||
*
|
||||
* @param joinPoint The AspectJ join point
|
||||
* @param auditLevel The current audit level
|
||||
* @return A map with standard audit data
|
||||
*/
|
||||
public Map<String, Object> createBaseAuditData(
|
||||
ProceedingJoinPoint joinPoint, AuditLevel auditLevel) {
|
||||
Map<String, Object> data = new HashMap<>();
|
||||
|
||||
// Common data for all levels
|
||||
data.put("timestamp", Instant.now().toString());
|
||||
|
||||
// Add principal: prefer MDC (captured on request thread) over SecurityContext
|
||||
// This ensures consistency in async contexts where SecurityContext may not be available
|
||||
String principal = MDC.get("auditPrincipal");
|
||||
if (principal == null) {
|
||||
// Fallback: capture from SecurityContext if running in request thread
|
||||
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
|
||||
principal = (auth != null && auth.getName() != null) ? auth.getName() : "system";
|
||||
}
|
||||
data.put("principal", principal);
|
||||
|
||||
// Add class name and method name only at VERBOSE level
|
||||
if (auditLevel.includes(AuditLevel.VERBOSE)) {
|
||||
data.put("className", joinPoint.getTarget().getClass().getName());
|
||||
data.put(
|
||||
"methodName",
|
||||
((MethodSignature) joinPoint.getSignature()).getMethod().getName());
|
||||
}
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Add HTTP-specific information to the audit data if available
|
||||
*
|
||||
* @param data The existing audit data map
|
||||
* @param httpMethod The HTTP method (GET, POST, etc.)
|
||||
* @param path The request path
|
||||
* @param auditLevel The current audit level
|
||||
*/
|
||||
public void addHttpData(
|
||||
Map<String, Object> data, String httpMethod, String path, AuditLevel auditLevel) {
|
||||
if (httpMethod == null || path == null) {
|
||||
return; // Skip if we don't have basic HTTP info
|
||||
}
|
||||
|
||||
// BASIC level HTTP data
|
||||
data.put("httpMethod", httpMethod);
|
||||
data.put("path", path);
|
||||
|
||||
// Get request attributes safely
|
||||
ServletRequestAttributes attrs =
|
||||
(ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
|
||||
if (attrs == null) {
|
||||
return; // No request context available
|
||||
}
|
||||
|
||||
HttpServletRequest req = attrs.getRequest();
|
||||
if (req == null) {
|
||||
return; // No request available
|
||||
}
|
||||
|
||||
// STANDARD level HTTP data
|
||||
if (auditLevel.includes(AuditLevel.STANDARD)) {
|
||||
// Use extracted client IP (supports X-Forwarded-For, X-Real-IP behind proxies)
|
||||
data.put("clientIp", extractClientIp(req));
|
||||
data.put(
|
||||
"sessionId",
|
||||
req.getSession(false) != null ? req.getSession(false).getId() : null);
|
||||
data.put("requestId", MDC.get("requestId"));
|
||||
|
||||
// Form data for POST/PUT/PATCH
|
||||
if (("POST".equalsIgnoreCase(httpMethod)
|
||||
|| "PUT".equalsIgnoreCase(httpMethod)
|
||||
|| "PATCH".equalsIgnoreCase(httpMethod))
|
||||
&& req.getContentType() != null) {
|
||||
|
||||
String contentType = req.getContentType();
|
||||
if (contentType.contains(MediaType.APPLICATION_FORM_URLENCODED_VALUE)
|
||||
|| contentType.contains(MediaType.MULTIPART_FORM_DATA_VALUE)) {
|
||||
|
||||
Map<String, String[]> params = new HashMap<>(req.getParameterMap());
|
||||
// Remove CSRF token from logged parameters
|
||||
params.remove("_csrf");
|
||||
|
||||
if (!params.isEmpty()) {
|
||||
data.put("formParams", params);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Add file information to the audit data if available
|
||||
*
|
||||
* @param data The existing audit data map
|
||||
* @param joinPoint The AspectJ join point
|
||||
* @param auditLevel The current audit level
|
||||
*/
|
||||
public void addFileData(
|
||||
Map<String, Object> data, ProceedingJoinPoint joinPoint, AuditLevel auditLevel) {
|
||||
if (auditLevel.includes(AuditLevel.STANDARD)) {
|
||||
List<MultipartFile> files = new ArrayList<>();
|
||||
|
||||
// Extract files from multiple sources:
|
||||
for (Object arg : joinPoint.getArgs()) {
|
||||
// 1. Direct MultipartFile arguments
|
||||
if (arg instanceof MultipartFile) {
|
||||
files.add((MultipartFile) arg);
|
||||
}
|
||||
// 2. MultipartFile[] arrays (ConvertToPdfRequest, HandleDataRequest, etc.)
|
||||
else if (arg instanceof MultipartFile[]) {
|
||||
files.addAll(Arrays.asList((MultipartFile[]) arg));
|
||||
}
|
||||
// 3. PDFFile-based objects (request wrappers like OptimizePdfRequest,
|
||||
// MergePdfsRequest)
|
||||
else if (arg instanceof PDFFile) {
|
||||
MultipartFile fileInput = ((PDFFile) arg).getFileInput();
|
||||
if (fileInput != null) {
|
||||
files.add(fileInput);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!files.isEmpty()) {
|
||||
List<Map<String, Object>> fileInfos =
|
||||
files.stream()
|
||||
.map(
|
||||
f -> {
|
||||
Map<String, Object> m = new HashMap<>();
|
||||
m.put("name", f.getOriginalFilename());
|
||||
m.put("size", f.getSize());
|
||||
m.put("type", f.getContentType());
|
||||
|
||||
// Add file metadata if enabled (independent of audit
|
||||
// level)
|
||||
if (auditConfig.isCaptureFileHash()
|
||||
|| auditConfig.isCapturePdfAuthor()) {
|
||||
addFileMetadata(m, f);
|
||||
}
|
||||
|
||||
return m;
|
||||
})
|
||||
.collect(Collectors.toList());
|
||||
|
||||
data.put("files", fileInfos);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract SHA-256 hash and PDF author metadata for a file
|
||||
*
|
||||
* @param fileData The file data map to add metadata to
|
||||
* @param file The MultipartFile to extract metadata from
|
||||
*/
|
||||
private void addFileMetadata(Map<String, Object> fileData, MultipartFile file) {
|
||||
// Extract SHA-256 hash if enabled (using streaming to avoid loading entire file into
|
||||
// memory)
|
||||
if (auditConfig.isCaptureFileHash()) {
|
||||
try (InputStream is = file.getInputStream()) {
|
||||
MessageDigest digest = MessageDigest.getInstance("SHA-256");
|
||||
DigestInputStream dis = new DigestInputStream(is, digest);
|
||||
byte[] buffer = new byte[8192];
|
||||
while (dis.read(buffer) != -1) {
|
||||
// Just read through the stream to compute digest
|
||||
}
|
||||
byte[] hashBytes = digest.digest();
|
||||
StringBuilder hexString = new StringBuilder();
|
||||
for (byte b : hashBytes) {
|
||||
hexString.append(String.format("%02x", b));
|
||||
}
|
||||
fileData.put("fileHash", hexString.toString());
|
||||
} catch (Exception e) {
|
||||
log.debug(
|
||||
"Could not calculate file hash for {}: {}",
|
||||
file.getOriginalFilename(),
|
||||
e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
// Extract PDF author if file is a PDF and enabled
|
||||
if (auditConfig.isCapturePdfAuthor()
|
||||
&& "application/pdf".equalsIgnoreCase(file.getContentType())) {
|
||||
try (InputStream is = file.getInputStream();
|
||||
PDDocument doc = pdfDocumentFactory.load(is, true)) {
|
||||
PDDocumentInformation info = doc.getDocumentInformation();
|
||||
if (info != null && info.getAuthor() != null) {
|
||||
fileData.put("pdfAuthor", info.getAuthor());
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.debug(
|
||||
"Could not extract PDF author from {}: {}",
|
||||
file.getOriginalFilename(),
|
||||
e.getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Add method arguments to the audit data
|
||||
*
|
||||
* @param data The existing audit data map
|
||||
* @param joinPoint The AspectJ join point
|
||||
* @param auditLevel The current audit level
|
||||
*/
|
||||
public void addMethodArguments(
|
||||
Map<String, Object> data, ProceedingJoinPoint joinPoint, AuditLevel auditLevel) {
|
||||
if (auditLevel.includes(AuditLevel.VERBOSE)) {
|
||||
MethodSignature sig = (MethodSignature) joinPoint.getSignature();
|
||||
String[] names = sig.getParameterNames();
|
||||
Object[] vals = joinPoint.getArgs();
|
||||
if (names != null && vals != null) {
|
||||
IntStream.range(0, names.length)
|
||||
.forEach(
|
||||
i -> {
|
||||
if (vals[i] != null) {
|
||||
// Convert objects to safe string representation
|
||||
data.put("arg_" + names[i], safeToString(vals[i], 500));
|
||||
} else {
|
||||
data.put("arg_" + names[i], null);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Safely convert an object to string with size limiting
|
||||
*
|
||||
* @param obj The object to convert
|
||||
* @param maxLength Maximum length of the resulting string
|
||||
* @return A safe string representation, truncated if needed
|
||||
*/
|
||||
public String safeToString(Object obj, int maxLength) {
|
||||
if (obj == null) {
|
||||
return "null";
|
||||
}
|
||||
|
||||
String result;
|
||||
try {
|
||||
// Handle common types directly to avoid toString() overhead
|
||||
if (obj instanceof String) {
|
||||
result = (String) obj;
|
||||
} else if (obj instanceof Number || obj instanceof Boolean) {
|
||||
result = obj.toString();
|
||||
} else if (obj instanceof byte[]) {
|
||||
result = "[binary data length=" + ((byte[]) obj).length + "]";
|
||||
} else {
|
||||
// For complex objects, use toString but handle exceptions
|
||||
result = obj.toString();
|
||||
}
|
||||
|
||||
// Truncate if necessary
|
||||
if (result != null && result.length() > maxLength) {
|
||||
return StringUtils.truncate(result, maxLength - 3) + "...";
|
||||
}
|
||||
|
||||
return result;
|
||||
} catch (Exception e) {
|
||||
// If toString() fails, return the class name
|
||||
return "[" + obj.getClass().getName() + " - toString() failed]";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine if a method should be audited based on config and annotation
|
||||
*
|
||||
* @param method The method to check
|
||||
* @param auditConfig The audit configuration
|
||||
* @return true if the method should be audited
|
||||
*/
|
||||
public boolean shouldAudit(Method method, AuditConfigurationProperties auditConfig) {
|
||||
// First check if we're running Enterprise Edition and audit is enabled - fast path
|
||||
if (!runningEE || !auditConfig.isEnabled()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check for annotation override
|
||||
Audited auditedAnnotation = method.getAnnotation(Audited.class);
|
||||
AuditLevel requiredLevel =
|
||||
(auditedAnnotation != null) ? auditedAnnotation.level() : AuditLevel.BASIC;
|
||||
|
||||
// Check if the required level is enabled
|
||||
return auditConfig.getAuditLevel().includes(requiredLevel);
|
||||
}
|
||||
|
||||
/**
|
||||
* Add timing and response status data to the audit record
|
||||
*
|
||||
* @param data The audit data to add to
|
||||
* @param startTime The start time in milliseconds
|
||||
* @param response The HTTP response (may be null for non-HTTP methods)
|
||||
* @param level The current audit level
|
||||
* @param isHttpRequest Whether this is an HTTP request (controller) or a regular method call
|
||||
*/
|
||||
public void addTimingData(
|
||||
Map<String, Object> data,
|
||||
long startTime,
|
||||
HttpServletResponse response,
|
||||
AuditLevel level,
|
||||
boolean isHttpRequest) {
|
||||
if (level.includes(AuditLevel.STANDARD)) {
|
||||
// For HTTP requests, let ControllerAuditAspect handle timing separately
|
||||
// For non-HTTP methods, add execution time here
|
||||
if (!isHttpRequest) {
|
||||
data.put("latencyMs", System.currentTimeMillis() - startTime);
|
||||
}
|
||||
|
||||
// Add HTTP status code if available
|
||||
if (response != null) {
|
||||
try {
|
||||
data.put("statusCode", response.getStatus());
|
||||
} catch (Exception e) {
|
||||
// Ignore - response might be in an inconsistent state
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the event type to use for auditing, considering annotations and context
|
||||
*
|
||||
* @param method The method being audited
|
||||
* @param controller The controller class
|
||||
* @param path The request path (may be null for non-HTTP methods)
|
||||
* @param httpMethod The HTTP method (may be null for non-HTTP methods)
|
||||
* @param annotation The @Audited annotation (may be null)
|
||||
* @return The resolved event type (never null)
|
||||
*/
|
||||
public AuditEventType resolveEventType(
|
||||
Method method,
|
||||
Class<?> controller,
|
||||
String path,
|
||||
String httpMethod,
|
||||
Audited annotation) {
|
||||
// First check if we have an explicit annotation
|
||||
if (annotation != null && annotation.type() != AuditEventType.HTTP_REQUEST) {
|
||||
return annotation.type();
|
||||
}
|
||||
|
||||
// For HTTP methods, infer based on controller and path
|
||||
if (httpMethod != null && path != null) {
|
||||
String cls = controller.getSimpleName().toLowerCase(Locale.ROOT);
|
||||
String pkg = controller.getPackage().getName().toLowerCase(Locale.ROOT);
|
||||
|
||||
if ("GET".equals(httpMethod)) {
|
||||
// Categorize GET requests as UI_DATA (UI data fetches)
|
||||
// API endpoints use POST/PUT/DELETE, or are specific operational endpoints
|
||||
if (isUiDataEndpoint(path)) {
|
||||
return AuditEventType.UI_DATA;
|
||||
}
|
||||
return AuditEventType.HTTP_REQUEST;
|
||||
}
|
||||
|
||||
if (cls.contains("user")
|
||||
|| cls.contains("auth")
|
||||
|| pkg.contains("auth")
|
||||
|| path.startsWith("/user")
|
||||
|| path.startsWith("/login")) {
|
||||
return AuditEventType.USER_PROFILE_UPDATE;
|
||||
} else if (cls.contains("admin")
|
||||
|| path.startsWith("/admin")
|
||||
|| path.startsWith("/settings")) {
|
||||
return AuditEventType.SETTINGS_CHANGED;
|
||||
} else if (cls.contains("file")
|
||||
|| path.startsWith("/file")
|
||||
|| RegexPatternUtils.getInstance()
|
||||
.getUploadDownloadPathPattern()
|
||||
.matcher(path)
|
||||
.matches()) {
|
||||
return AuditEventType.FILE_OPERATION;
|
||||
}
|
||||
}
|
||||
|
||||
// Default for non-HTTP methods or when no specific match
|
||||
return AuditEventType.PDF_PROCESS;
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine the appropriate audit level to use
|
||||
*
|
||||
* @param method The method to check
|
||||
* @param defaultLevel The default level to use if no annotation present
|
||||
* @param auditConfig The audit configuration
|
||||
* @return The audit level to use
|
||||
*/
|
||||
public AuditLevel getEffectiveAuditLevel(
|
||||
Method method, AuditLevel defaultLevel, AuditConfigurationProperties auditConfig) {
|
||||
Audited auditedAnnotation = method.getAnnotation(Audited.class);
|
||||
if (auditedAnnotation != null) {
|
||||
// Method has @Audited - use its level
|
||||
return auditedAnnotation.level();
|
||||
}
|
||||
|
||||
// Use default level (typically from global config)
|
||||
return defaultLevel;
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine the appropriate audit event type to use
|
||||
*
|
||||
* @param method The method being audited
|
||||
* @param controller The controller class
|
||||
* @param path The request path
|
||||
* @param httpMethod The HTTP method
|
||||
* @return The determined audit event type
|
||||
*/
|
||||
public AuditEventType determineAuditEventType(
|
||||
Method method, Class<?> controller, String path, String httpMethod) {
|
||||
// First check for explicit annotation
|
||||
Audited auditedAnnotation = method.getAnnotation(Audited.class);
|
||||
if (auditedAnnotation != null) {
|
||||
return auditedAnnotation.type();
|
||||
}
|
||||
|
||||
// Otherwise infer from controller and path
|
||||
String cls = controller.getSimpleName().toLowerCase(Locale.ROOT);
|
||||
String pkg = controller.getPackage().getName().toLowerCase(Locale.ROOT);
|
||||
|
||||
if ("GET".equals(httpMethod)) return AuditEventType.HTTP_REQUEST;
|
||||
|
||||
if (cls.contains("user")
|
||||
|| cls.contains("auth")
|
||||
|| pkg.contains("auth")
|
||||
|| path.startsWith("/user")
|
||||
|| path.startsWith("/login")) {
|
||||
return AuditEventType.USER_PROFILE_UPDATE;
|
||||
} else if (cls.contains("admin")
|
||||
|| path.startsWith("/admin")
|
||||
|| path.startsWith("/settings")) {
|
||||
return AuditEventType.SETTINGS_CHANGED;
|
||||
} else if (cls.contains("file")
|
||||
|| path.startsWith("/file")
|
||||
|| RegexPatternUtils.getInstance()
|
||||
.getUploadDownloadPathPattern()
|
||||
.matcher(path)
|
||||
.matches()) {
|
||||
return AuditEventType.FILE_OPERATION;
|
||||
} else {
|
||||
return AuditEventType.PDF_PROCESS;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the current HTTP request if available
|
||||
*
|
||||
* @return The current request or null if not in a request context
|
||||
*/
|
||||
public HttpServletRequest getCurrentRequest() {
|
||||
ServletRequestAttributes attrs =
|
||||
(ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
|
||||
return attrs != null ? attrs.getRequest() : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a GET request is for a static resource
|
||||
*
|
||||
* @param request The HTTP request
|
||||
* @return true if this is a static resource request
|
||||
*/
|
||||
public boolean isStaticResourceRequest(HttpServletRequest request) {
|
||||
return request != null
|
||||
&& !RequestUriUtils.isTrackableResource(
|
||||
request.getContextPath(), request.getRequestURI());
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a GET request is a continuous polling call that should be excluded from STANDARD
|
||||
* level
|
||||
*
|
||||
* @param request The HTTP request
|
||||
* @return true if this is a polling/continuous call that should be excluded from STANDARD level
|
||||
*/
|
||||
public boolean isPollingCall(HttpServletRequest request) {
|
||||
if (request == null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
String path = request.getRequestURI();
|
||||
String method = request.getMethod();
|
||||
|
||||
// Only filter GET requests
|
||||
if (!"GET".equalsIgnoreCase(method)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Polling endpoints excluded from STANDARD level auditing.
|
||||
// Use exact/prefix matching to avoid accidental exclusions on unrelated paths.
|
||||
return path.equals("/api/v1/auth/me")
|
||||
|| path.equals("/api/v1/app-config")
|
||||
|| path.equals("/api/v1/footer-info")
|
||||
|| path.equals("/api/v1/admin/license-info")
|
||||
|| path.equals("/api/v1/endpoints-availability")
|
||||
|| path.equals("/health")
|
||||
|| path.startsWith("/health/")
|
||||
|| path.equals("/metrics")
|
||||
|| path.startsWith("/metrics/")
|
||||
|| path.equals("/actuator/health")
|
||||
|| path.startsWith("/actuator/health/")
|
||||
|| path.equals("/actuator/metrics")
|
||||
|| path.startsWith("/actuator/metrics/");
|
||||
}
|
||||
|
||||
// ========== HELPER METHODS ==========
|
||||
|
||||
/**
|
||||
* Check if an endpoint is an API endpoint. API endpoints match /api/v1/* pattern but exclude
|
||||
* /api/v1/auth/*, /api/v1/ui-data/*, /api/v1/proprietary/ui-data/*, /api/v1/config/*, and
|
||||
* /api/v1/admin/license-info. Everything else is considered "UI".
|
||||
*
|
||||
* @param endpoint The endpoint path to check
|
||||
* @return true if this is an API endpoint, false if it's a UI endpoint
|
||||
*/
|
||||
private boolean isUiDataEndpoint(String endpoint) {
|
||||
if (endpoint == null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// UI data endpoints include auth, settings, config, user/team management, and UI data
|
||||
// fetches
|
||||
return endpoint.startsWith("/api/v1/auth/")
|
||||
|| endpoint.startsWith("/api/v1/ui-data/")
|
||||
|| endpoint.startsWith("/api/v1/proprietary/ui-data/")
|
||||
|| endpoint.startsWith("/api/v1/config/")
|
||||
|| endpoint.startsWith("/api/v1/admin/settings/")
|
||||
|| endpoint.startsWith("/api/v1/user/")
|
||||
|| endpoint.startsWith("/api/v1/users/")
|
||||
|| endpoint.equals("/api/v1/admin/license-info")
|
||||
|| endpoint.equals("/login");
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if operation results (return values) should be captured
|
||||
*
|
||||
* @return true if captureOperationResults is enabled in config
|
||||
*/
|
||||
public boolean shouldCaptureOperationResults() {
|
||||
return auditConfig.isCaptureOperationResults();
|
||||
}
|
||||
|
||||
/** Get the current authenticated username or "system" if none */
|
||||
private String getCurrentUsername() {
|
||||
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
|
||||
if (auth != null && auth.getName() != null && !"anonymousUser".equals(auth.getName())) {
|
||||
return auth.getName();
|
||||
}
|
||||
|
||||
// Refresh endpoint runs without normal JWT authentication so SecurityContext may be
|
||||
// anonymous.
|
||||
// In that case, derive principal from a signature-verified refresh token for attribution.
|
||||
HttpServletRequest req = getCurrentRequest();
|
||||
String tokenSubject = extractRefreshTokenSubject(req);
|
||||
if (StringUtils.isNotBlank(tokenSubject)) {
|
||||
return tokenSubject;
|
||||
}
|
||||
|
||||
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() {
|
||||
String principal = getCurrentUsername();
|
||||
return principal;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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() {
|
||||
String origin = determineOrigin();
|
||||
return origin;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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";
|
||||
}
|
||||
|
||||
// Refresh endpoint may still be anonymous in SecurityContext; infer origin from a
|
||||
// verified JWT if present.
|
||||
HttpServletRequest req = getCurrentRequest();
|
||||
String refreshOrigin = extractRefreshTokenOrigin(req);
|
||||
if (refreshOrigin != null) {
|
||||
return refreshOrigin;
|
||||
}
|
||||
|
||||
// System or unauthenticated
|
||||
return "SYSTEM";
|
||||
} catch (Exception e) {
|
||||
log.debug("Could not determine origin for audit event", e);
|
||||
return "SYSTEM";
|
||||
}
|
||||
}
|
||||
|
||||
private String extractRefreshTokenSubject(HttpServletRequest request) {
|
||||
if (!isRefreshEndpoint(request)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
String token = jwtService.extractToken(request);
|
||||
if (StringUtils.isBlank(token)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
String subject = jwtService.extractUsernameAllowExpired(token);
|
||||
return StringUtils.isNotBlank(subject) ? subject : null;
|
||||
} catch (Exception e) {
|
||||
log.debug("Could not extract refresh token subject for audit attribution", e);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private String extractRefreshTokenOrigin(HttpServletRequest request) {
|
||||
if (!isRefreshEndpoint(request)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
String token = jwtService.extractToken(request);
|
||||
if (StringUtils.isBlank(token)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
Map<String, Object> claims = jwtService.extractClaimsAllowExpired(token);
|
||||
Object authType = claims.get("authType");
|
||||
if (authType != null && "API".equalsIgnoreCase(String.valueOf(authType))) {
|
||||
return "API";
|
||||
}
|
||||
// Any verified non-API JWT refresh request is web/SSO user traffic.
|
||||
return "WEB";
|
||||
} catch (Exception e) {
|
||||
log.debug("Could not extract refresh token origin for audit attribution", e);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private boolean isRefreshEndpoint(HttpServletRequest request) {
|
||||
if (request == null) {
|
||||
return false;
|
||||
}
|
||||
String path = request.getRequestURI();
|
||||
return StringUtils.isNotBlank(path) && path.endsWith("/api/v1/auth/refresh");
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract client IP address from the request, preferring X-Forwarded-For header for proxy/load
|
||||
* balancer support. IMPORTANT: Must be called in the request thread before async execution to
|
||||
* preserve the IP.
|
||||
*
|
||||
* @param request The HTTP request
|
||||
* @return The client IP address, or null if not available
|
||||
*/
|
||||
public String extractClientIp(HttpServletRequest request) {
|
||||
if (request == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Try X-Forwarded-For first (set by proxies/load balancers)
|
||||
String forwardedFor = request.getHeader("X-Forwarded-For");
|
||||
if (StringUtils.isNotBlank(forwardedFor)) {
|
||||
// X-Forwarded-For can contain multiple IPs, take the first one (original client)
|
||||
String[] ips = forwardedFor.split(",");
|
||||
return ips[0].trim();
|
||||
}
|
||||
|
||||
// Try X-Real-IP (used by some proxies)
|
||||
String realIp = request.getHeader("X-Real-IP");
|
||||
if (StringUtils.isNotBlank(realIp)) {
|
||||
return realIp;
|
||||
}
|
||||
|
||||
// Fall back to remote address
|
||||
return request.getRemoteAddr();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,7 +16,7 @@ public final class SecretMasker {
|
||||
private static final Pattern SENSITIVE =
|
||||
RegexPatternUtils.getInstance()
|
||||
.getPattern(
|
||||
"(?i)(password|token|secret|api[_-]?key|authorization|auth|jwt|cred|cert)");
|
||||
"(?i)\\b(password|token|secret|api[_-]?key|authorization|auth|jwt|cred|cert)\\b");
|
||||
|
||||
private SecretMasker() {}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user