diff --git a/app/common/src/main/java/stirling/software/common/aop/AutoJobAspect.java b/app/common/src/main/java/stirling/software/common/aop/AutoJobAspect.java index 5e7ce13274..adfad7704b 100644 --- a/app/common/src/main/java/stirling/software/common/aop/AutoJobAspect.java +++ b/app/common/src/main/java/stirling/software/common/aop/AutoJobAspect.java @@ -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 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 Supplier wrapWithMDC(Supplier supplier) { + final Map captured = MDC.getCopyOfContextMap(); + return () -> { + final Map 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(); + } + } + }; + } } diff --git a/app/common/src/main/java/stirling/software/common/model/ApplicationProperties.java b/app/common/src/main/java/stirling/software/common/model/ApplicationProperties.java index 7ab6553ba5..cb30a085fc 100644 --- a/app/common/src/main/java/stirling/software/common/model/ApplicationProperties.java +++ b/app/common/src/main/java/stirling/software/common/model/ApplicationProperties.java @@ -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 diff --git a/app/common/src/main/java/stirling/software/common/service/PdfMetadataService.java b/app/common/src/main/java/stirling/software/common/service/PdfMetadataService.java index 0d2eebc10f..7b74111870 100644 --- a/app/common/src/main/java/stirling/software/common/service/PdfMetadataService.java +++ b/app/common/src/main/java/stirling/software/common/service/PdfMetadataService.java @@ -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); diff --git a/app/core/build.gradle b/app/core/build.gradle index 475ca3b7ad..20533fecb6 100644 --- a/app/core/build.gradle +++ b/app/core/build.gradle @@ -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' diff --git a/app/core/src/main/resources/settings.yml.template b/app/core/src/main/resources/settings.yml.template index 4ffa2f9fac..94ceb34613 100644 --- a/app/core/src/main/resources/settings.yml.template +++ b/app/core/src/main/resources/settings.yml.template @@ -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 diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/audit/AuditAspect.java b/app/proprietary/src/main/java/stirling/software/proprietary/audit/AuditAspect.java index 0975562ee2..f42a0f29c1 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/audit/AuditAspect.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/audit/AuditAspect.java @@ -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 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()); } } } diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/audit/AuditEventType.java b/app/proprietary/src/main/java/stirling/software/proprietary/audit/AuditEventType.java index 49b06ecad7..433244a971 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/audit/AuditEventType.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/audit/AuditEventType.java @@ -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"); diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/audit/AuditLevel.java b/app/proprietary/src/main/java/stirling/software/proprietary/audit/AuditLevel.java index 4a14773c42..59adc2af80 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/audit/AuditLevel.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/audit/AuditLevel.java @@ -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); diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/audit/AuditUtils.java b/app/proprietary/src/main/java/stirling/software/proprietary/audit/AuditUtils.java deleted file mode 100644 index af950c0fd3..0000000000 --- a/app/proprietary/src/main/java/stirling/software/proprietary/audit/AuditUtils.java +++ /dev/null @@ -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 createBaseAuditData( - ProceedingJoinPoint joinPoint, AuditLevel auditLevel) { - Map 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 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 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 data, ProceedingJoinPoint joinPoint, AuditLevel auditLevel) { - if (auditLevel.includes(AuditLevel.STANDARD)) { - List files = - Arrays.stream(joinPoint.getArgs()) - .filter(a -> a instanceof MultipartFile) - .map(a -> (MultipartFile) a) - .collect(Collectors.toList()); - - if (!files.isEmpty()) { - List> fileInfos = - files.stream() - .map( - f -> { - Map 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 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 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()); - } -} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/audit/ControllerAuditAspect.java b/app/proprietary/src/main/java/stirling/software/proprietary/audit/ControllerAuditAspect.java index 78d6bf2aba..0d777d9481 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/audit/ControllerAuditAspect.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/audit/ControllerAuditAspect.java @@ -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 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 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 } diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/config/AuditConfigurationProperties.java b/app/proprietary/src/main/java/stirling/software/proprietary/config/AuditConfigurationProperties.java index 16b019a3bc..366d91b11c 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/config/AuditConfigurationProperties.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/config/AuditConfigurationProperties.java @@ -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); } /** diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/controller/api/AuditRestController.java b/app/proprietary/src/main/java/stirling/software/proprietary/controller/api/AuditRestController.java index 468943974e..a208a46a81 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/controller/api/AuditRestController.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/controller/api/AuditRestController.java @@ -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 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 events; + // Convert arrays to lists + List eventTypeList = + (eventTypes != null && eventTypes.length > 0) ? Arrays.asList(eventTypes) : null; + List 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 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 currentEvents = auditRepository.findByTimestampAfter(start); + List 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 hourlyData = auditRepository.histogramByHourBetween(start, now); + Map 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 events) { + if (events.isEmpty()) { + return AuditMetrics.builder().build(); + } + + // Count by type + Map eventsByType = + events.stream() + .collect( + Collectors.groupingBy( + PersistentAuditEvent::getType, Collectors.counting())); + + // Count by principal (user) + Map 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 topTools = new HashMap<>(); + + for (PersistentAuditEvent event : events) { + if (event.getData() != null) { + try { + @SuppressWarnings("unchecked") + Map 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 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 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 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 eventTypeList = + (eventTypes != null && eventTypes.length > 0) ? Arrays.asList(eventTypes) : null; + List 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 exportAsCsv(List events) { + private ResponseEntity exportAsCsv(List events, String fields) { + // Parse selected fields (comma-separated: + // date,username,tool,documentName,author,fileHash,ipAddress,etc) + Set 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 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 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 exportAsDefaultCsv(List 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 extractEventData( + PersistentAuditEvent event, DateTimeFormatter formatter) { + Map 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 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> files = + (List>) eventData.get("files"); + if (files != null && !files.isEmpty()) { + Map 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 exportAsJson(List events) { try { byte[] jsonBytes = objectMapper.writeValueAsBytes(events); @@ -431,4 +858,60 @@ public class AuditRestController { private List labels; private List 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 eventsByType; + private Map eventsByUser; + private Map topTools; + private Map 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 eventsByType; + private Map eventsByUser; + private Map 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()); + } + } } diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/controller/api/ProprietaryUIDataController.java b/app/proprietary/src/main/java/stirling/software/proprietary/controller/api/ProprietaryUIDataController.java index 0417134644..2c77ddddd3 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/controller/api/ProprietaryUIDataController.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/controller/api/ProprietaryUIDataController.java @@ -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 diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/controller/api/UsageRestController.java b/app/proprietary/src/main/java/stirling/software/proprietary/controller/api/UsageRestController.java index 3afdf0d7bf..9f4fa470ba 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/controller/api/UsageRestController.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/controller/api/UsageRestController.java @@ -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 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 httpEvents = - auditRepository.findByTypeForExport(AuditEventType.HTTP_REQUEST.name()); + int lookbackDays = Math.max(1, Math.min(days, 365)); + + // Get audit events filtered by type + List events = getEventsByDataType(dataType, lookbackDays); // Count visits per endpoint Map 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 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 diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/repository/PersistentAuditEventRepository.java b/app/proprietary/src/main/java/stirling/software/proprietary/repository/PersistentAuditEventRepository.java index 121d8a95cb..27bca098b3 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/repository/PersistentAuditEventRepository.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/repository/PersistentAuditEventRepository.java @@ -68,6 +68,10 @@ public interface PersistentAuditEventRepository extends JpaRepository findByTypeForExport(@Param("type") String type); + @Query("SELECT e FROM PersistentAuditEvent e WHERE e.type = :type AND e.timestamp > :startDate") + List findByTypeAndTimestampAfterForExport( + @Param("type") String type, @Param("startDate") Instant startDate); + @Query("SELECT e FROM PersistentAuditEvent e WHERE e.timestamp BETWEEN :startDate AND :endDate") List findAllByTimestampBetweenForExport( @Param("startDate") Instant startDate, @Param("endDate") Instant endDate); @@ -171,4 +175,88 @@ public interface PersistentAuditEventRepository extends JpaRepository findTopByPrincipalOrderByTimestampDesc(String principal); Optional 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 findByTypeIn(@Param("types") List types, Pageable pageable); + + @Query("SELECT e FROM PersistentAuditEvent e WHERE e.principal IN :principals") + Page findByPrincipalIn( + @Param("principals") List principals, Pageable pageable); + + @Query( + "SELECT e FROM PersistentAuditEvent e WHERE e.type IN :types AND e.timestamp BETWEEN :startDate AND :endDate") + Page findByTypeInAndTimestampBetween( + @Param("types") List 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 findByPrincipalInAndTimestampBetween( + @Param("principals") List 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 findByTypeInAndPrincipalIn( + @Param("types") List types, + @Param("principals") List 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 findByTypeInAndPrincipalInAndTimestampBetween( + @Param("types") List types, + @Param("principals") List 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 findByTypeInForExport(@Param("types") List types); + + @Query("SELECT e FROM PersistentAuditEvent e WHERE e.principal IN :principals") + List findByPrincipalInForExport( + @Param("principals") List principals); + + @Query( + "SELECT e FROM PersistentAuditEvent e WHERE e.type IN :types AND e.timestamp BETWEEN :startDate AND :endDate") + List findByTypeInAndTimestampBetweenForExport( + @Param("types") List 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 findByPrincipalInAndTimestampBetweenForExport( + @Param("principals") List 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 findByTypeInAndPrincipalInForExport( + @Param("types") List types, @Param("principals") List 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 findByTypeInAndPrincipalInAndTimestampBetweenForExport( + @Param("types") List types, + @Param("principals") List 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 findAllExceptTypeForExport(@Param("excludeType") String excludeType); + + @Query( + "SELECT e FROM PersistentAuditEvent e WHERE e.type != :excludeType AND e.timestamp > :startDate") + List findAllExceptTypeAndTimestampAfterForExport( + @Param("excludeType") String excludeType, @Param("startDate") Instant startDate); } diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/service/UserService.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/service/UserService.java index dbff599974..2930d248ba 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/security/service/UserService.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/service/UserService.java @@ -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; } diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/service/AuditService.java b/app/proprietary/src/main/java/stirling/software/proprietary/service/AuditService.java index 57ae13c185..9f50b9f595 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/service/AuditService.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/service/AuditService.java @@ -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 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 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 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 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 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 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 createBaseAuditData( + ProceedingJoinPoint joinPoint, AuditLevel auditLevel) { + Map 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 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 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 data, ProceedingJoinPoint joinPoint, AuditLevel auditLevel) { + if (auditLevel.includes(AuditLevel.STANDARD)) { + List 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> fileInfos = + files.stream() + .map( + f -> { + Map 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 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 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 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 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(); + } } diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/util/SecretMasker.java b/app/proprietary/src/main/java/stirling/software/proprietary/util/SecretMasker.java index daead1edb7..a3975de194 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/util/SecretMasker.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/util/SecretMasker.java @@ -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() {} diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 9eaaaf6dd5..02a124429a 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -72,6 +72,7 @@ "react-i18next": "^15.7.3", "react-rnd": "^10.5.2", "react-router-dom": "^7.9.1", + "recharts": "^3.7.0", "signature_pad": "^5.0.4", "smol-toml": "^1.4.2", "tailwindcss": "^4.1.13", @@ -3013,6 +3014,42 @@ "react": "16.x || 17.x || 18.x || 19.x" } }, + "node_modules/@reduxjs/toolkit": { + "version": "2.11.2", + "resolved": "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-2.11.2.tgz", + "integrity": "sha512-Kd6kAHTA6/nUpp8mySPqj3en3dm0tdMIgbttnQ1xFMVpufoj+ADi8pXLBsd4xzTRHQa7t/Jv8W5UnCuW4kuWMQ==", + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.0.0", + "@standard-schema/utils": "^0.3.0", + "immer": "^11.0.0", + "redux": "^5.0.1", + "redux-thunk": "^3.1.0", + "reselect": "^5.1.0" + }, + "peerDependencies": { + "react": "^16.9.0 || ^17.0.0 || ^18 || ^19", + "react-redux": "^7.2.1 || ^8.1.3 || ^9.0.0" + }, + "peerDependenciesMeta": { + "react": { + "optional": true + }, + "react-redux": { + "optional": true + } + } + }, + "node_modules/@reduxjs/toolkit/node_modules/immer": { + "version": "11.1.4", + "resolved": "https://registry.npmjs.org/immer/-/immer-11.1.4.tgz", + "integrity": "sha512-XREFCPo6ksxVzP4E0ekD5aMdf8WMwmdNaz6vuvxgI40UaEiu6q3p8X52aU6GdyvLY3XXX/8R7JOTXStz/nBbRw==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/immer" + } + }, "node_modules/@rolldown/pluginutils": { "version": "1.0.0-rc.2", "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.2.tgz", @@ -3397,6 +3434,18 @@ "url": "https://github.com/sindresorhus/is?sponsor=1" } }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "license": "MIT" + }, + "node_modules/@standard-schema/utils": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@standard-schema/utils/-/utils-0.3.0.tgz", + "integrity": "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==", + "license": "MIT" + }, "node_modules/@stripe/react-stripe-js": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/@stripe/react-stripe-js/-/react-stripe-js-4.0.2.tgz", @@ -4495,6 +4544,69 @@ "assertion-error": "^2.0.1" } }, + "node_modules/@types/d3-array": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz", + "integrity": "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==", + "license": "MIT" + }, + "node_modules/@types/d3-color": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz", + "integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==", + "license": "MIT" + }, + "node_modules/@types/d3-ease": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-ease/-/d3-ease-3.0.2.tgz", + "integrity": "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==", + "license": "MIT" + }, + "node_modules/@types/d3-interpolate": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz", + "integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==", + "license": "MIT", + "dependencies": { + "@types/d3-color": "*" + } + }, + "node_modules/@types/d3-path": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-3.1.1.tgz", + "integrity": "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==", + "license": "MIT" + }, + "node_modules/@types/d3-scale": { + "version": "4.0.9", + "resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.9.tgz", + "integrity": "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==", + "license": "MIT", + "dependencies": { + "@types/d3-time": "*" + } + }, + "node_modules/@types/d3-shape": { + "version": "3.1.8", + "resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.1.8.tgz", + "integrity": "sha512-lae0iWfcDeR7qt7rA88BNiqdvPS5pFVPpo5OfjElwNaT2yyekbM0C9vK+yqBqEmHr6lDkRnYNoTBYlAgJa7a4w==", + "license": "MIT", + "dependencies": { + "@types/d3-path": "*" + } + }, + "node_modules/@types/d3-time": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.4.tgz", + "integrity": "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==", + "license": "MIT" + }, + "node_modules/@types/d3-timer": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz", + "integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==", + "license": "MIT" + }, "node_modules/@types/deep-eql": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", @@ -4637,6 +4749,12 @@ "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==", "license": "MIT" }, + "node_modules/@types/use-sync-external-store": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz", + "integrity": "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==", + "license": "MIT" + }, "node_modules/@types/ws": { "version": "8.18.1", "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz", @@ -6329,6 +6447,127 @@ "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", "license": "MIT" }, + "node_modules/d3-array": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz", + "integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==", + "license": "ISC", + "dependencies": { + "internmap": "1 - 2" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-color": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz", + "integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-ease": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz", + "integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-format": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.2.tgz", + "integrity": "sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-interpolate": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz", + "integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==", + "license": "ISC", + "dependencies": { + "d3-color": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-path": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz", + "integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-scale": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz", + "integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==", + "license": "ISC", + "dependencies": { + "d3-array": "2.10.0 - 3", + "d3-format": "1 - 3", + "d3-interpolate": "1.2.0 - 3", + "d3-time": "2.1.1 - 3", + "d3-time-format": "2 - 4" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-shape": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz", + "integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==", + "license": "ISC", + "dependencies": { + "d3-path": "^3.1.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-time": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz", + "integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==", + "license": "ISC", + "dependencies": { + "d3-array": "2 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-time-format": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.0.tgz", + "integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==", + "license": "ISC", + "dependencies": { + "d3-time": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-timer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz", + "integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, "node_modules/data-uri-to-buffer": { "version": "6.0.2", "resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-6.0.2.tgz", @@ -6405,6 +6644,12 @@ "dev": true, "license": "MIT" }, + "node_modules/decimal.js-light": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/decimal.js-light/-/decimal.js-light-2.5.1.tgz", + "integrity": "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==", + "license": "MIT" + }, "node_modules/decompress-response": { "version": "10.0.0", "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-10.0.0.tgz", @@ -6963,6 +7208,16 @@ "node": ">= 0.4" } }, + "node_modules/es-toolkit": { + "version": "1.44.0", + "resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.44.0.tgz", + "integrity": "sha512-6penXeZalaV88MM3cGkFZZfOoLGWshWWfdy0tWw/RlVVyhvMaWSBTOvXNeiW3e5FwdS5ePW0LGEu17zT139ktg==", + "license": "MIT", + "workspaces": [ + "docs", + "benchmarks" + ] + }, "node_modules/esbuild": { "version": "0.27.3", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.3.tgz", @@ -8116,6 +8371,16 @@ "integrity": "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==", "license": "MIT" }, + "node_modules/immer": { + "version": "10.2.0", + "resolved": "https://registry.npmjs.org/immer/-/immer-10.2.0.tgz", + "integrity": "sha512-d/+XTN3zfODyjr89gM3mPq1WNX2B8pYsu7eORitdwyA2sBubnTl3laYlBk4sXY5FUa5qTZGBDPJICVbvqzjlbw==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/immer" + } + }, "node_modules/import-fresh": { "version": "3.3.1", "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", @@ -8179,6 +8444,15 @@ "node": "^18.17.0 || >=20.5.0" } }, + "node_modules/internmap": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz", + "integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, "node_modules/ip-address": { "version": "10.1.0", "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.1.0.tgz", @@ -10803,6 +11077,29 @@ "react-dom": "^0.14 || ^15.0.0 || ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, + "node_modules/react-redux": { + "version": "9.2.0", + "resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.2.0.tgz", + "integrity": "sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==", + "license": "MIT", + "dependencies": { + "@types/use-sync-external-store": "^0.0.6", + "use-sync-external-store": "^1.4.0" + }, + "peerDependencies": { + "@types/react": "^18.2.25 || ^19", + "react": "^18.0 || ^19", + "redux": "^5.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "redux": { + "optional": true + } + } + }, "node_modules/react-remove-scroll": { "version": "2.7.2", "resolved": "https://registry.npmjs.org/react-remove-scroll/-/react-remove-scroll-2.7.2.tgz", @@ -11112,6 +11409,42 @@ "node": ">=8.10.0" } }, + "node_modules/recharts": { + "version": "3.7.0", + "resolved": "https://registry.npmjs.org/recharts/-/recharts-3.7.0.tgz", + "integrity": "sha512-l2VCsy3XXeraxIID9fx23eCb6iCBsxUQDnE8tWm6DFdszVAO7WVY/ChAD9wVit01y6B2PMupYiMmQwhgPHc9Ew==", + "license": "MIT", + "workspaces": [ + "www" + ], + "dependencies": { + "@reduxjs/toolkit": "1.x.x || 2.x.x", + "clsx": "^2.1.1", + "decimal.js-light": "^2.5.1", + "es-toolkit": "^1.39.3", + "eventemitter3": "^5.0.1", + "immer": "^10.1.1", + "react-redux": "8.x.x || 9.x.x", + "reselect": "5.1.1", + "tiny-invariant": "^1.3.3", + "use-sync-external-store": "^1.2.2", + "victory-vendor": "^37.0.2" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-is": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/recharts/node_modules/eventemitter3": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz", + "integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==", + "license": "MIT" + }, "node_modules/redent": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz", @@ -11126,6 +11459,21 @@ "node": ">=8" } }, + "node_modules/redux": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz", + "integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==", + "license": "MIT" + }, + "node_modules/redux-thunk": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/redux-thunk/-/redux-thunk-3.1.0.tgz", + "integrity": "sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw==", + "license": "MIT", + "peerDependencies": { + "redux": "^5.0.0" + } + }, "node_modules/require-directory": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", @@ -11174,6 +11522,12 @@ "node": ">=10.13.0" } }, + "node_modules/reselect": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/reselect/-/reselect-5.1.1.tgz", + "integrity": "sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w==", + "license": "MIT" + }, "node_modules/resize-observer-polyfill": { "version": "1.5.1", "resolved": "https://registry.npmjs.org/resize-observer-polyfill/-/resize-observer-polyfill-1.5.1.tgz", @@ -12195,6 +12549,12 @@ "dev": true, "license": "Apache-2.0" }, + "node_modules/tiny-invariant": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz", + "integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==", + "license": "MIT" + }, "node_modules/tinybench": { "version": "2.9.0", "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", @@ -12661,6 +13021,15 @@ } } }, + "node_modules/use-sync-external-store": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz", + "integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==", + "license": "MIT", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, "node_modules/util-deprecate": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", @@ -12685,6 +13054,28 @@ "spdx-expression-parse": "^3.0.0" } }, + "node_modules/victory-vendor": { + "version": "37.3.6", + "resolved": "https://registry.npmjs.org/victory-vendor/-/victory-vendor-37.3.6.tgz", + "integrity": "sha512-SbPDPdDBYp+5MJHhBCAyI7wKM3d5ivekigc2Dk2s7pgbZ9wIgIBYGVw4zGHBml/qTFbexrofXW6Gu4noGxrOwQ==", + "license": "MIT AND ISC", + "dependencies": { + "@types/d3-array": "^3.0.3", + "@types/d3-ease": "^3.0.0", + "@types/d3-interpolate": "^3.0.1", + "@types/d3-scale": "^4.0.2", + "@types/d3-shape": "^3.1.0", + "@types/d3-time": "^3.0.0", + "@types/d3-timer": "^3.0.0", + "d3-array": "^3.1.6", + "d3-ease": "^3.0.1", + "d3-interpolate": "^3.0.1", + "d3-scale": "^4.0.2", + "d3-shape": "^3.1.0", + "d3-time": "^3.0.0", + "d3-timer": "^3.0.1" + } + }, "node_modules/visit-values": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/visit-values/-/visit-values-2.0.0.tgz", diff --git a/frontend/package.json b/frontend/package.json index 8e2bfbfbd5..9221e4706e 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -68,6 +68,7 @@ "react-i18next": "^15.7.3", "react-rnd": "^10.5.2", "react-router-dom": "^7.9.1", + "recharts": "^3.7.0", "signature_pad": "^5.0.4", "smol-toml": "^1.4.2", "tailwindcss": "^4.1.13", diff --git a/frontend/public/locales/en-GB/translation.toml b/frontend/public/locales/en-GB/translation.toml index a1aa8b99f6..08c21e0090 100644 --- a/frontend/public/locales/en-GB/translation.toml +++ b/frontend/public/locales/en-GB/translation.toml @@ -1164,19 +1164,43 @@ title = "Security" [admin.settings.security.audit] label = "Audit Logging" +showDetails = "Show audit level details" +hideDetails = "Hide audit level details" [admin.settings.security.audit.enabled] description = "Track user actions and system events for compliance and security monitoring" label = "Enable Audit Logging" [admin.settings.security.audit.level] -description = "0=OFF, 1=BASIC, 2=STANDARD, 3=VERBOSE" +description = "Audit Level: OFF (0) = no logging | BASIC (1) = file modifications (compress/split/merge/etc) & settings | STANDARD (2) = file operations + user actions | VERBOSE (3) = all requests including polling. Higher levels include all events from lower levels." label = "Audit Level" +[admin.settings.security.audit.levelDetails] +off = "No audit logging (except critical security events)" +basic = "PDF file operations (compress, split, merge, etc.) and settings changes. Captures: operation status (success/failure), method parameters, timing. Minimal log volume." +standard = "BASIC events plus: user login/logout, account changes, general GET requests. Excludes continuous polling calls (/auth/me, /app-config, /health, etc.) to reduce log noise. Ideal for most deployments." +verbose = "STANDARD events plus: continuous polling calls, all GET requests, detailed timing. Warning: high log volume and performance impact." + [admin.settings.security.audit.retentionDays] -description = "Number of days to retain audit logs" +description = "Number of days to retain audit logs (0 = infinite retention)" label = "Audit Retention (days)" +[admin.settings.security.audit.advancedOptions] +title = "Advanced Options" +description = "The following options increase processing time and memory usage. Enable only if truly needed." + +[admin.settings.security.audit.captureFileHash] +description = "Extract SHA-256 hash of uploaded PDF files for integrity verification. Independent of audit level." +label = "Capture File Hash (SHA-256)" + +[admin.settings.security.audit.capturePdfAuthor] +description = "Extract author field from PDF documents during processing. Requires PDF parsing, increases latency. Independent of audit level." +label = "Capture PDF Author Metadata" + +[admin.settings.security.audit.captureOperationResults] +description = "Capture method return values. Not recommended: significantly increases log volume and disk usage." +label = "Capture Operation Results" + [admin.settings.security.csrfDisabled] description = "Disable Cross-Site Request Forgery protection (not recommended)" label = "Disable CSRF Protection" @@ -1540,72 +1564,152 @@ tags = "attachments,add,remove,embed,file" title = "Add Attachments" [audit] +configureAudit = "Configure Audit Logging" +configureAuditMessage = "Adjust audit logging level, retention period, and other settings in the Security & Authentication section." disabled = "Audit logging is disabled" disabledMessage = "Enable audit logging in your application configuration to track system events." enterpriseRequired = "Enterprise License Required" enterpriseRequiredMessage = "The audit logging system is an enterprise feature. Please upgrade to an enterprise license to access audit logs and analytics." +goToSettings = "Go to Audit Settings" notAvailable = "Audit system not available" notAvailableMessage = "The audit system is not configured or not available." -[audit.charts] -byType = "Events by Type" -byUser = "Events by User" -day = "Day" -error = "Error loading charts" -month = "Month" -overTime = "Events Over Time" -title = "Audit Dashboard" -week = "Week" - [audit.error] title = "Error loading audit system" -[audit.events] -actions = "Actions" -clearFilters = "Clear" -details = "Details" -endDate = "End date" -error = "Error loading events" -eventDetails = "Event Details" -filterByType = "Filter by type" -filterByUser = "Filter by user" -ipAddress = "IP Address" -noEvents = "No events found" -startDate = "Start date" -timestamp = "Timestamp" -title = "Audit Events" -type = "Type" -user = "User" -viewDetails = "View Details" - [audit.export] clearFilters = "Clear" description = "Export audit events to CSV or JSON format. Use filters to limit the exported data." endDate = "End date" error = "Failed to export data" exportButton = "Export Data" +fieldAuthor = "Author (from PDF)" +fieldDate = "Date" +fieldDocumentName = "Document Name" +fieldFileHash = "File Hash (SHA-256)" +fieldIpAddress = "IP Address" +fieldOperationResults = "Operation Results" +fieldOutcome = "Outcome (Success/Failure)" +fieldTool = "Tool" +fieldUsername = "Username" filterByType = "Filter by type" filterByUser = "Filter by user" filters = "Filters (Optional)" format = "Export Format" +selectFields = "Select Fields to Include" startDate = "Start date" title = "Export Audit Data" +verboseRequired = "Requires VERBOSE audit level" [audit.systemStatus] +autoRefresh = "Auto-refresh" +autoRefreshLabel = "Auto-refresh every 30s" +captureBySettings = "Enable in settings" +capturedFields = "Captured Fields" +date = "Date" days = "days" disabled = "Disabled" +documentName = "Document Name" enabled = "Enabled" +fileHash = "File Hash" level = "Audit Level" +pdfAuthor = "PDF Author" retention = "Retention Period" status = "Audit Logging" title = "System Status" +tool = "Tool" totalEvents = "Total Events" +username = "Username" +verboseOnly = "VERBOSE only" + +[audit.stats] +activeUsers = "Active Users" +attention = "Attention needed" +avgLatency = "Avg Latency" +error = "Error loading statistics" +errorLoadingStats = "Failed to load statistics" +excellent = "Excellent" +good = "Good" +noData = "N/A" +successRate = "Success Rate" +title = "Summary" +totalEvents = "Total Events" +vsLastPeriod = "vs last period" + +[audit.charts] +byTool = "Top Tools Used" +byType = "Events by Type" +byUser = "Top Users" +day = "Day" +error = "Error loading charts" +hourlyActivity = "Hourly Activity" +month = "Month" +noData = "No data for this period" +overTime = "Events Over Time" +title = "Audit Dashboard" +week = "Week" + +[audit.events] +actions = "Actions" +author = "Author" +clearFilters = "Clear" +details = "Details" +documentName = "Document" +endDate = "End date" +error = "Error loading events" +eventDetails = "Event Details" +failure = "Failure" +fileHash = "File Hash" +filterByType = "Filter by type" +filterByUser = "Filter by user" +ipAddress = "IP Address" +noEvents = "No events found" +outcome = "Status" +sortAsc = "Sort ascending" +sortDesc = "Sort descending" +startDate = "Start date" +success = "Success" +timestamp = "Timestamp" +title = "Audit Events" +type = "Type" +user = "User" +viewDetails = "View Details" + +[audit.filters] +allOutcomes = "All" +failureOnly = "Failures only" +last7Days = "Last 7 days" +last30Days = "Last 30 days" +outcomeFilter = "Outcome" +quickPresets = "Quick filters" +successOnly = "Success only" +thisMonth = "This month" +today = "Today" [audit.tabs] +clearData = "Clear Data" dashboard = "Dashboard" events = "Audit Events" export = "Export" +[audit.clearData] +cancel = "Cancel" +codeDoesNotMatch = "Code does not match" +codePlaceholder = "Type the code here" +confirmationCode = "Confirmation Code" +confirmationRequired = "Delete All Audit Data" +confirmMessage = "This will permanently remove all audit logs. Enter the confirmation code below to proceed." +confirmTitle = "Please confirm you want to delete" +deleteButton = "Delete" +enterCode = "Confirmation Code" +enterCodeBelow = "Enter the code exactly as shown above (case-sensitive)" +initiateDelete = "Delete All Data" +irreversible = "IRREVERSIBLE" +success = "Success" +successMessage = "All audit data has been cleared successfully" +warning1 = "This action cannot be undone" +warning2 = "Deleting audit data will permanently remove all historical audit logs, including security events, user activities, and file operations from the database." + [auth] accessDenied = "Access Denied" insufficientPermissions = "You do not have permission to perform this action." @@ -6539,9 +6643,13 @@ tags = "web-capture,save-page,web-to-doc,archive" title = "URL To PDF" [usage] +aboutUsageAnalytics = "About Usage Analytics" +configureSettings = "Configure Analytics Settings" error = "Error loading usage statistics" noData = "No data available" noDataMessage = "No usage statistics are currently available." +usageAnalyticsExplanation = "Usage analytics track endpoint requests and tool usage patterns. Combined with the Audit Logging dashboard, you get complete visibility into system activity, performance, and security events." +viewAuditLogs = "View Audit Logs" [usage.chart] title = "Endpoint Usage Chart" diff --git a/frontend/src/core/components/shared/AppConfigModal.css b/frontend/src/core/components/shared/AppConfigModal.css index b36fc31771..5db477a129 100644 --- a/frontend/src/core/components/shared/AppConfigModal.css +++ b/frontend/src/core/components/shared/AppConfigModal.css @@ -2,12 +2,12 @@ .modal-container { display: flex; gap: 0; - height: 37.5rem; /* 600px */ + height: 45rem; /* 720px */ } .modal-nav { width: 15rem; /* 240px */ - height: 37.5rem; /* 600px */ + height: 45rem; /* 720px */ border-top-left-radius: 0.75rem; /* 12px */ border-bottom-left-radius: 0.75rem; /* 12px */ overflow: hidden; @@ -108,7 +108,7 @@ .modal-content { flex: 1; - height: 37.5rem; /* 600px */ + height: 45rem; /* 720px */ display: flex; flex-direction: column; overflow: hidden; diff --git a/frontend/src/core/components/shared/AppConfigModal.tsx b/frontend/src/core/components/shared/AppConfigModal.tsx index 42e6099e36..26806bcf55 100644 --- a/frontend/src/core/components/shared/AppConfigModal.tsx +++ b/frontend/src/core/components/shared/AppConfigModal.tsx @@ -118,7 +118,7 @@ const AppConfigModalInner: React.FC = ({ opened, onClose }) opened={opened} onClose={handleClose} title={null} - size={isMobile ? "100%" : 980} + size={isMobile ? "100%" : 1100} centered radius="lg" withCloseButton={false} diff --git a/frontend/src/core/services/auditService.ts b/frontend/src/core/services/auditService.ts index 30951b67b2..4b37f12bf9 100644 --- a/frontend/src/core/services/auditService.ts +++ b/frontend/src/core/services/auditService.ts @@ -5,6 +5,10 @@ export interface AuditSystemStatus { level: string; retentionDays: number; totalEvents: number; + pdfMetadataEnabled: boolean; + captureFileHash: boolean; + capturePdfAuthor: boolean; + captureOperationResults: boolean; } export interface AuditEvent { @@ -36,12 +40,32 @@ export interface AuditChartsData { } export interface AuditFilters { - eventType?: string; - username?: string; + eventType?: string | string[]; + username?: string | string[]; startDate?: string; endDate?: string; + outcome?: string; page?: number; pageSize?: number; + fields?: string; +} + +export interface AuditStats { + totalEvents: number; + prevTotalEvents: number; + uniqueUsers: number; + prevUniqueUsers: number; + successRate: number; // 0–100 + prevSuccessRate: number; + avgLatencyMs: number; + prevAvgLatencyMs: number; + errorCount: number; + topEventType: string; + topUser: string; + eventsByType: Record; + eventsByUser: Record; + topTools: Record; + hourlyDistribution: Record; // "00"–"23" keys } const auditService = { @@ -60,9 +84,23 @@ const auditService = { level: data.auditLevel, retentionDays: data.retentionDays, totalEvents: 0, // Will be fetched separately + pdfMetadataEnabled: data.pdfMetadataEnabled ?? false, + captureFileHash: data.captureFileHash ?? false, + capturePdfAuthor: data.capturePdfAuthor ?? false, + captureOperationResults: data.captureOperationResults ?? false, }; }, + /** + * Get audit statistics and KPI data + */ + async getStats(timePeriod: 'day' | 'week' | 'month' = 'week'): Promise { + const response = await apiClient.get('/api/v1/proprietary/ui-data/audit-stats', { + params: { period: timePeriod }, + }); + return response.data; + }, + /** * Get audit events with pagination and filters */ @@ -84,7 +122,7 @@ const auditService = { }, /** - * Export audit data + * Export audit data with custom field selection */ async exportData( format: 'csv' | 'json', @@ -112,6 +150,13 @@ const auditService = { const response = await apiClient.get('/api/v1/proprietary/ui-data/audit-users'); return response.data; }, + + /** + * Clear all audit data from the database (irreversible) + */ + async clearAllAuditData(): Promise { + await apiClient.post('/api/v1/proprietary/ui-data/audit-clear-all', {}); + }, }; export default auditService; diff --git a/frontend/src/proprietary/components/shared/config/configSections/AdminAuditSection.tsx b/frontend/src/proprietary/components/shared/config/configSections/AdminAuditSection.tsx index 1e8602be38..53939a45cb 100644 --- a/frontend/src/proprietary/components/shared/config/configSections/AdminAuditSection.tsx +++ b/frontend/src/proprietary/components/shared/config/configSections/AdminAuditSection.tsx @@ -1,18 +1,23 @@ import React, { useState, useEffect } from 'react'; -import { Tabs, Loader, Alert, Stack } from '@mantine/core'; +import { Tabs, Loader, Alert, Stack, Text, Button, Accordion } from '@mantine/core'; import { useTranslation } from 'react-i18next'; +import { useNavigate } from 'react-router-dom'; import auditService, { AuditSystemStatus as AuditStatus } from '@app/services/auditService'; import AuditSystemStatus from '@app/components/shared/config/configSections/audit/AuditSystemStatus'; +import AuditStatsCards from '@app/components/shared/config/configSections/audit/AuditStatsCards'; import AuditChartsSection from '@app/components/shared/config/configSections/audit/AuditChartsSection'; import AuditEventsTable from '@app/components/shared/config/configSections/audit/AuditEventsTable'; import AuditExportSection from '@app/components/shared/config/configSections/audit/AuditExportSection'; +import AuditClearDataSection from '@app/components/shared/config/configSections/audit/AuditClearDataSection'; import { useLoginRequired } from '@app/hooks/useLoginRequired'; import LoginRequiredBanner from '@app/components/shared/config/LoginRequiredBanner'; import { useAppConfig } from '@app/contexts/AppConfigContext'; import EnterpriseRequiredBanner from '@app/components/shared/config/EnterpriseRequiredBanner'; +import LocalIcon from '@app/components/shared/LocalIcon'; const AdminAuditSection: React.FC = () => { const { t } = useTranslation(); + const navigate = useNavigate(); const { loginEnabled } = useLoginRequired(); const { config } = useAppConfig(); const licenseType = config?.license ?? 'NORMAL'; @@ -21,6 +26,7 @@ const AdminAuditSection: React.FC = () => { const [systemStatus, setSystemStatus] = useState(null); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); + const [timePeriod, setTimePeriod] = useState<'day' | 'week' | 'month'>('week'); useEffect(() => { const fetchSystemStatus = async () => { @@ -52,6 +58,10 @@ const AdminAuditSection: React.FC = () => { level: 'INFO', retentionDays: 90, totalEvents: 1234, + pdfMetadataEnabled: true, + captureFileHash: true, + capturePdfAuthor: true, + captureOperationResults: false, }); setLoading(false); } @@ -94,6 +104,8 @@ const AdminAuditSection: React.FC = () => { ); } + const isEnabled = loginEnabled && hasEnterpriseLicense; + return ( @@ -101,32 +113,95 @@ const AdminAuditSection: React.FC = () => { show={!hasEnterpriseLicense} featureName={t('settings.licensingAnalytics.audit', 'Audit')} /> + + {/* Info banner about audit settings */} + {isEnabled && ( + } + title={t('audit.configureAudit', 'Configure Audit Logging')} + color="blue" + variant="light" + > + + + {t( + 'audit.configureAuditMessage', + 'Adjust audit logging level, retention period, and other settings in the Security & Authentication section.' + )} + + + + + )} + - {systemStatus.enabled ? ( + {systemStatus?.enabled ? ( - + {t('audit.tabs.dashboard', 'Dashboard')} - + {t('audit.tabs.events', 'Audit Events')} - + {t('audit.tabs.export', 'Export')} + + {t('audit.tabs.clearData', 'Clear Data')} + - + + {/* Stats Cards - Always Visible */} + + + {/* Charts in Accordion - Collapsible */} + + + + {t('audit.charts.overTime', 'Events Over Time')} + + + + + + + - + - + + + + + ) : ( diff --git a/frontend/src/proprietary/components/shared/config/configSections/AdminSecuritySection.tsx b/frontend/src/proprietary/components/shared/config/configSections/AdminSecuritySection.tsx index 612d74e5c9..29dc0c211f 100644 --- a/frontend/src/proprietary/components/shared/config/configSections/AdminSecuritySection.tsx +++ b/frontend/src/proprietary/components/shared/config/configSections/AdminSecuritySection.tsx @@ -33,6 +33,9 @@ interface SecuritySettingsData { enabled?: boolean; level?: number; retentionDays?: number; + captureFileHash?: boolean; + capturePdfAuthor?: boolean; + captureOperationResults?: boolean; }; html?: { urlSecurity?: { @@ -144,7 +147,10 @@ export default function AdminSecuritySection() { // Premium audit settings 'premium.enterpriseFeatures.audit.enabled': audit?.enabled, 'premium.enterpriseFeatures.audit.level': audit?.level, - 'premium.enterpriseFeatures.audit.retentionDays': audit?.retentionDays + 'premium.enterpriseFeatures.audit.retentionDays': audit?.retentionDays, + 'premium.enterpriseFeatures.audit.captureFileHash': audit?.captureFileHash, + 'premium.enterpriseFeatures.audit.capturePdfAuthor': audit?.capturePdfAuthor, + 'premium.enterpriseFeatures.audit.captureOperationResults': audit?.captureOperationResults }; // System HTML settings @@ -553,6 +559,64 @@ export default function AdminSecuritySection() { disabled={!loginEnabled} /> + + } title={t('admin.settings.security.audit.advancedOptions.title', 'Advanced Options')}> + {t('admin.settings.security.audit.advancedOptions.description', 'The following options increase processing time and memory usage. Enable only if truly needed.')} + + +
+
+ {t('admin.settings.security.audit.captureFileHash.label', 'Capture File Hash')} + + {t('admin.settings.security.audit.captureFileHash.description', 'Store MD5 hash of processed files for audit trail verification')} + +
+ + setSettings({ ...settings, audit: { ...settings?.audit, captureFileHash: e.target.checked } })} + disabled={!loginEnabled} + /> + + +
+ +
+
+ {t('admin.settings.security.audit.capturePdfAuthor.label', 'Capture PDF Author')} + + {t('admin.settings.security.audit.capturePdfAuthor.description', 'Extract author field from PDF documents during processing')} + +
+ + setSettings({ ...settings, audit: { ...settings?.audit, capturePdfAuthor: e.target.checked } })} + disabled={!loginEnabled} + /> + + +
+ +
+
+ {t('admin.settings.security.audit.captureOperationResults.label', 'Capture Operation Results')} + + {t('admin.settings.security.audit.captureOperationResults.description', 'Store output file information and processing results in audit logs')} + +
+ + setSettings({ ...settings, audit: { ...settings?.audit, captureOperationResults: e.target.checked } })} + disabled={!loginEnabled} + /> + + +
diff --git a/frontend/src/proprietary/components/shared/config/configSections/AdminUsageSection.tsx b/frontend/src/proprietary/components/shared/config/configSections/AdminUsageSection.tsx index 18f379f03c..fe551c8bc8 100644 --- a/frontend/src/proprietary/components/shared/config/configSections/AdminUsageSection.tsx +++ b/frontend/src/proprietary/components/shared/config/configSections/AdminUsageSection.tsx @@ -1,15 +1,7 @@ import React, { useState, useEffect, useCallback } from 'react'; -import { - Stack, - Group, - Text, - Button, - SegmentedControl, - Loader, - Alert, - Card, -} from '@mantine/core'; +import { Stack, Group, Text, Button, SegmentedControl, Loader, Alert, Card } from '@mantine/core'; import { useTranslation } from 'react-i18next'; +import { useNavigate } from 'react-router-dom'; import usageAnalyticsService, { EndpointStatisticsResponse } from '@app/services/usageAnalyticsService'; import UsageAnalyticsChart from '@app/components/shared/config/configSections/usage/UsageAnalyticsChart'; import UsageAnalyticsTable from '@app/components/shared/config/configSections/usage/UsageAnalyticsTable'; @@ -21,6 +13,7 @@ import EnterpriseRequiredBanner from '@app/components/shared/config/EnterpriseRe const AdminUsageSection: React.FC = () => { const { t } = useTranslation(); + const navigate = useNavigate(); const { loginEnabled } = useLoginRequired(); const { config } = useAppConfig(); const licenseType = config?.license ?? 'NORMAL'; @@ -30,7 +23,7 @@ const AdminUsageSection: React.FC = () => { const [loading, setLoading] = useState(true); const [error, setError] = useState(null); const [displayMode, setDisplayMode] = useState<'top10' | 'top20' | 'all'>('top10'); - const [dataType, setDataType] = useState<'all' | 'api' | 'ui'>('all'); + const [dataType, setDataType] = useState<'all' | 'api' | 'ui'>('api'); const buildDemoUsageData = useCallback((): EndpointStatisticsResponse => { const totalVisits = 15847; @@ -180,6 +173,43 @@ const AdminUsageSection: React.FC = () => { featureName={t('settings.licensingAnalytics.usageAnalytics', 'Usage Analytics')} /> + {/* Info banner about usage analytics and audit relationship */} + {loginEnabled && hasEnterpriseLicense && ( + } + title={t('usage.aboutUsageAnalytics', 'About Usage Analytics')} + color="cyan" + variant="light" + > + + + {t( + 'usage.usageAnalyticsExplanation', + 'Usage analytics track endpoint requests and tool usage patterns. Combined with the Audit Logging dashboard, you get complete visibility into system activity, performance, and security events.' + )} + + + + + + + + )} + {/* Controls */} diff --git a/frontend/src/proprietary/components/shared/config/configSections/audit/AuditChartsSection.tsx b/frontend/src/proprietary/components/shared/config/configSections/audit/AuditChartsSection.tsx index f454ddbdfc..2edb6ce448 100644 --- a/frontend/src/proprietary/components/shared/config/configSections/audit/AuditChartsSection.tsx +++ b/frontend/src/proprietary/components/shared/config/configSections/audit/AuditChartsSection.tsx @@ -1,65 +1,59 @@ import React, { useState, useEffect } from 'react'; -import { Card, Text, Group, Stack, SegmentedControl, Loader, Alert, Box, SimpleGrid } from '@mantine/core'; +import { + Card, + Text, + Group, + Stack, + SegmentedControl, + Loader, + Alert, + Box, + SimpleGrid, +} from '@mantine/core'; +import { + AreaChart, + Area, + BarChart, + Bar, + Cell, + XAxis, + YAxis, + CartesianGrid, + Tooltip, + ResponsiveContainer, +} from 'recharts'; import { useTranslation } from 'react-i18next'; import auditService, { AuditChartsData } from '@app/services/auditService'; -interface SimpleBarChartProps { - data: { label: string; value: number }[]; - title: string; - color?: string; -} +// Event type color mapping +const EVENT_TYPE_COLORS: Record = { + USER_LOGIN: 'var(--mantine-color-green-6)', + USER_LOGOUT: 'var(--mantine-color-gray-5)', + USER_FAILED_LOGIN: 'var(--mantine-color-red-6)', + USER_PROFILE_UPDATE: 'var(--mantine-color-blue-6)', + SETTINGS_CHANGED: 'var(--mantine-color-orange-6)', + FILE_OPERATION: 'var(--mantine-color-cyan-6)', + PDF_PROCESS: 'var(--mantine-color-violet-6)', + UI_DATA: 'var(--mantine-color-teal-6)', + HTTP_REQUEST: 'var(--mantine-color-indigo-6)', +}; -const SimpleBarChart: React.FC = ({ data, title, color = 'blue' }) => { - const maxValue = Math.max(...data.map((d) => d.value), 1); - - return ( - - - {title} - - - {data.map((item, index) => ( - - - - {item.label} - - - {item.value} - - - - - - - ))} - - - ); +const getEventTypeColor = (type: string): string => { + return EVENT_TYPE_COLORS[type] || 'var(--mantine-color-blue-6)'; }; interface AuditChartsSectionProps { loginEnabled?: boolean; + timePeriod?: 'day' | 'week' | 'month'; + onTimePeriodChange?: (period: 'day' | 'week' | 'month') => void; } -const AuditChartsSection: React.FC = ({ loginEnabled = true }) => { +const AuditChartsSection: React.FC = ({ + loginEnabled = true, + timePeriod = 'week', + onTimePeriodChange, +}) => { const { t } = useTranslation(); - const [timePeriod, setTimePeriod] = useState<'day' | 'week' | 'month'>('week'); const [chartsData, setChartsData] = useState(null); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); @@ -72,7 +66,7 @@ const AuditChartsSection: React.FC = ({ loginEnabled = const data = await auditService.getChartsData(timePeriod); setChartsData(data); } catch (err) { - setError(err instanceof Error ? err.message : 'Failed to load charts'); + setError(err instanceof Error ? err.message : t('audit.charts.error', 'Failed to load charts')); } finally { setLoading(false); } @@ -81,7 +75,7 @@ const AuditChartsSection: React.FC = ({ loginEnabled = if (loginEnabled) { fetchChartsData(); } else { - // Provide example charts data when login is disabled + // Demo data when login disabled setChartsData({ eventsByType: { labels: ['LOGIN', 'LOGOUT', 'SETTINGS_CHANGE', 'FILE_UPLOAD', 'FILE_DOWNLOAD'], @@ -122,62 +116,161 @@ const AuditChartsSection: React.FC = ({ loginEnabled = return null; } + // Transform data for Recharts + const eventsOverTimeData = chartsData.eventsOverTime.labels.map((label, index) => ({ + name: label, + value: chartsData.eventsOverTime.values[index], + })); + const eventsByTypeData = chartsData.eventsByType.labels.map((label, index) => ({ - label, + type: label, value: chartsData.eventsByType.values[index], })); const eventsByUserData = chartsData.eventsByUser.labels.map((label, index) => ({ - label, + user: label, value: chartsData.eventsByUser.values[index], })); - const eventsOverTimeData = chartsData.eventsOverTime.labels.map((label, index) => ({ - label, - value: chartsData.eventsOverTime.values[index], - })); - return ( - - - - - {t('audit.charts.title', 'Audit Dashboard')} - - { - if (!loginEnabled) return; - setTimePeriod(value as 'day' | 'week' | 'month'); - }} - disabled={!loginEnabled} - data={[ - { label: t('audit.charts.day', 'Day'), value: 'day' }, - { label: t('audit.charts.week', 'Week'), value: 'week' }, - { label: t('audit.charts.month', 'Month'), value: 'month' }, - ]} - /> - + + {/* Header with time period selector */} + + + {t('audit.charts.title', 'Audit Dashboard')} + + { + onTimePeriodChange?.(value as 'day' | 'week' | 'month'); + }} + disabled={!loginEnabled} + data={[ + { label: t('audit.charts.day', 'Day'), value: 'day' }, + { label: t('audit.charts.week', 'Week'), value: 'week' }, + { label: t('audit.charts.month', 'Month'), value: 'month' }, + ]} + /> + - - - - - - - + {/* Full-width Events Over Time Chart */} + + + + {t('audit.charts.overTime', 'Events Over Time')} + + + {eventsOverTimeData.length > 0 ? ( + + + + + + + + + + + + + + + + ) : ( + + {t('audit.charts.noData', 'No data for this period')} + + )} + + + + + {/* Two-column grid for remaining charts */} + + {/* Events by Type Chart */} + + + + {t('audit.charts.byType', 'Events by Type')} + + + {eventsByTypeData.length > 0 ? ( + + + + + + + + {eventsByTypeData.map((entry, index) => ( + + ))} + + + + ) : ( + + {t('audit.charts.noData', 'No data')} + + )} + + + + + {/* Top Users Chart (Horizontal) */} + + + + {t('audit.charts.byUser', 'Top Users')} + + + {eventsByUserData.length > 0 ? ( + + + + + + + + + + ) : ( + + {t('audit.charts.noData', 'No data')} + + )} + + + + + ); }; diff --git a/frontend/src/proprietary/components/shared/config/configSections/audit/AuditClearDataSection.tsx b/frontend/src/proprietary/components/shared/config/configSections/audit/AuditClearDataSection.tsx new file mode 100644 index 0000000000..85837306fa --- /dev/null +++ b/frontend/src/proprietary/components/shared/config/configSections/audit/AuditClearDataSection.tsx @@ -0,0 +1,191 @@ +import React, { useState } from 'react'; +import { Card, Stack, Text, PasswordInput, Button, Group, Alert, Code, Badge } from '@mantine/core'; +import { useTranslation } from 'react-i18next'; +import auditService from '@app/services/auditService'; +import LocalIcon from '@app/components/shared/LocalIcon'; + +interface AuditClearDataSectionProps { + loginEnabled?: boolean; +} + +const AuditClearDataSection: React.FC = ({ loginEnabled = true }) => { + const { t } = useTranslation(); + const [confirmationCode, setConfirmationCode] = useState(''); + const [generatedCode, setGeneratedCode] = useState(''); + const [showConfirmation, setShowConfirmation] = useState(false); + const [clearing, setClearing] = useState(false); + const [error, setError] = useState(null); + const [success, setSuccess] = useState(false); + + const handleInitiateDeletion = () => { + const code = Math.random().toString(36).substring(2, 10).toUpperCase(); + setGeneratedCode(code); + setConfirmationCode(''); + setShowConfirmation(true); + setError(null); + }; + + const resetForm = () => { + setConfirmationCode(''); + setGeneratedCode(''); + setShowConfirmation(false); + setError(null); + }; + + const handleClearData = async () => { + if (confirmationCode !== generatedCode) { + setError(t('audit.clearData.codeDoesNotMatch', 'Code does not match')); + return; + } + + try { + setClearing(true); + setError(null); + await auditService.clearAllAuditData(); + setSuccess(true); + resetForm(); + // Auto-dismiss success message after 5 seconds + setTimeout(() => setSuccess(false), 5000); + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to clear audit data'); + } finally { + setClearing(false); + } + }; + + if (success) { + return ( + + } + title={t('audit.clearData.success', 'Success')} + onClose={() => setSuccess(false)} + closeButtonLabel="Close alert" + withCloseButton + > + {t('audit.clearData.successMessage', 'All audit data has been cleared successfully')} + + + ); + } + + if (showConfirmation) { + return ( + + } + title={t('audit.clearData.confirmTitle', 'Please confirm you want to delete')} + > + + {t('audit.clearData.confirmMessage', 'This will permanently remove all audit logs. Enter the confirmation code below to proceed.')} + + + + + +
+ + {t('audit.clearData.confirmationCode', 'Confirmation Code')} + + + {generatedCode} + + + {t('audit.clearData.enterCodeBelow', 'Enter the code exactly as shown above (case-sensitive)')} + +
+ + setConfirmationCode(e.currentTarget.value)} + disabled={!loginEnabled} + error={ + confirmationCode && confirmationCode !== generatedCode + ? t('audit.clearData.codeDoesNotMatch', 'Code does not match') + : false + } + /> + + {error && ( + }> + {error} + + )} + + + + + +
+
+
+ ); + } + + return ( + + } + title={t('audit.clearData.warning1', 'This action cannot be undone')} + > + + {t('audit.clearData.warning2', 'Deleting audit data will permanently remove all historical audit logs, including security events, user activities, and file operations from the database.')} + + + + + + + + {t('audit.clearData.confirmationRequired', 'Delete All Audit Data')} + + + {t('audit.clearData.irreversible', 'IRREVERSIBLE')} + + + + + + + + ); +}; + +export default AuditClearDataSection; diff --git a/frontend/src/proprietary/components/shared/config/configSections/audit/AuditEventsTable.tsx b/frontend/src/proprietary/components/shared/config/configSections/audit/AuditEventsTable.tsx index d8bf714b0a..ba22d37221 100644 --- a/frontend/src/proprietary/components/shared/config/configSections/audit/AuditEventsTable.tsx +++ b/frontend/src/proprietary/components/shared/config/configSections/audit/AuditEventsTable.tsx @@ -11,18 +11,27 @@ import { Loader, Alert, Table, + Badge, + UnstyledButton, } from '@mantine/core'; import { useTranslation } from 'react-i18next'; import auditService, { AuditEvent } from '@app/services/auditService'; import { Z_INDEX_OVER_CONFIG_MODAL } from '@app/styles/zIndex'; import { useAuditFilters } from '@app/hooks/useAuditFilters'; import AuditFiltersForm from '@app/components/shared/config/configSections/audit/AuditFiltersForm'; +import LocalIcon from '@app/components/shared/LocalIcon'; interface AuditEventsTableProps { loginEnabled?: boolean; + captureFileHash?: boolean; + capturePdfAuthor?: boolean; } -const AuditEventsTable: React.FC = ({ loginEnabled = true }) => { +const AuditEventsTable: React.FC = ({ + loginEnabled = true, + captureFileHash = false, + capturePdfAuthor = false, +}) => { const { t } = useTranslation(); const [events, setEvents] = useState([]); const [totalPages, setTotalPages] = useState(0); @@ -30,6 +39,11 @@ const AuditEventsTable: React.FC = ({ loginEnabled = true const [loading, setLoading] = useState(true); const [error, setError] = useState(null); const [selectedEvent, setSelectedEvent] = useState(null); + const [sortKey, setSortKey] = useState<'timestamp' | 'eventType' | 'username' | 'ipAddress' | null>('timestamp'); + const [sortDir, setSortDir] = useState<'asc' | 'desc'>('desc'); + const showAuthor = capturePdfAuthor; + const showFileHash = captureFileHash; + const totalColumns = 5 + (showAuthor ? 1 : 0) + (showFileHash ? 1 : 0); // Use shared filters hook const { filters, eventTypes, users, handleFilterChange, handleClearFilters } = useAuditFilters({ @@ -122,6 +136,69 @@ const AuditEventsTable: React.FC = ({ loginEnabled = true return new Date(dateString).toLocaleString(); }; + // Sort handling + const toggleSort = (key: 'timestamp' | 'eventType' | 'username' | 'ipAddress') => { + if (sortKey === key) { + setSortDir(sortDir === 'asc' ? 'desc' : 'asc'); + } else { + setSortKey(key); + setSortDir('asc'); + } + }; + + const getSortIcon = (key: 'timestamp' | 'eventType' | 'username' | 'ipAddress') => { + if (sortKey !== key) return 'unfold-more'; + return sortDir === 'asc' ? 'expand-less' : 'expand-more'; + }; + + // Event type colors + const EVENT_TYPE_COLORS: Record = { + USER_LOGIN: 'green', + USER_LOGOUT: 'gray', + USER_FAILED_LOGIN: 'red', + USER_PROFILE_UPDATE: 'blue', + SETTINGS_CHANGED: 'orange', + FILE_OPERATION: 'cyan', + PDF_PROCESS: 'violet', + UI_DATA: 'gray', + HTTP_REQUEST: 'indigo', + }; + + const getEventTypeColor = (type: string): string => { + return EVENT_TYPE_COLORS[type] || 'blue'; + }; + + // Apply sorting to current events + const sortedEvents = [...events].sort((a, b) => { + let aVal: any; + let bVal: any; + + switch (sortKey) { + case 'timestamp': + aVal = new Date(a.timestamp).getTime(); + bVal = new Date(b.timestamp).getTime(); + break; + case 'eventType': + aVal = a.eventType; + bVal = b.eventType; + break; + case 'username': + aVal = a.username; + bVal = b.username; + break; + case 'ipAddress': + aVal = a.ipAddress; + bVal = b.ipAddress; + break; + default: + return 0; + } + + if (aVal < bVal) return sortDir === 'asc' ? -1 : 1; + if (aVal > bVal) return sortDir === 'asc' ? 1 : -1; + return 0; + }); + return ( @@ -150,6 +227,7 @@ const AuditEventsTable: React.FC = ({ loginEnabled = true ) : ( <> +
= ({ loginEnabled = true > - - {t('audit.events.timestamp', 'Timestamp')} + + toggleSort('timestamp')} style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', cursor: 'pointer', userSelect: 'none' }}> + {t('audit.events.timestamp', 'Timestamp')} + + + + + toggleSort('eventType')} style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', cursor: 'pointer', userSelect: 'none' }}> + {t('audit.events.type', 'Type')} + + + + + toggleSort('username')} style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', cursor: 'pointer', userSelect: 'none' }}> + {t('audit.events.user', 'User')} + + - {t('audit.events.type', 'Type')} - - - {t('audit.events.user', 'User')} - - - {t('audit.events.ipAddress', 'IP Address')} + {t('audit.events.documentName', 'Document Name')} + {showAuthor && ( + + {t('audit.events.author', 'Author')} + + )} + {showFileHash && ( + + {t('audit.events.fileHash', 'File Hash')} + + )} {t('audit.events.actions', 'Actions')} - {events.length === 0 ? ( + {sortedEvents.length === 0 ? ( - - - {t('audit.events.noEvents', 'No events found')} - + + + + + + {t('audit.events.noEvents', 'No events found')} + + + ) : ( - events.map((event) => ( - - - {formatDate(event.timestamp)} - - - {event.eventType} - - - {event.username} - - - {event.ipAddress} - - - - - - )) + sortedEvents.map((event) => { + // Extract document name, author, hash from details.files if available + let documentName = ''; + let author = ''; + let fileHash = ''; + if (event.details && typeof event.details === 'object') { + const details = event.details as Record; + const files = details.files; + if (Array.isArray(files) && files.length > 0) { + const firstFile = files[0] as Record; + documentName = firstFile.name || ''; + if (showAuthor || showFileHash) { + author = firstFile.pdfAuthor || ''; + fileHash = firstFile.fileHash ? firstFile.fileHash.substring(0, 16) + '...' : ''; + } + } + } + + return ( + + + {formatDate(event.timestamp)} + + + + {event.eventType} + + + + {event.username} + + + + {documentName || '—'} + + + {showAuthor && ( + + {author} + + )} + {showFileHash && ( + + + {fileHash} + + + )} + + + + + ); + }) )}
@@ -228,6 +365,7 @@ const AuditEventsTable: React.FC = ({ loginEnabled = true /> )} +
)}
diff --git a/frontend/src/proprietary/components/shared/config/configSections/audit/AuditExportSection.tsx b/frontend/src/proprietary/components/shared/config/configSections/audit/AuditExportSection.tsx index 04cc260e91..53f6c53b59 100644 --- a/frontend/src/proprietary/components/shared/config/configSections/audit/AuditExportSection.tsx +++ b/frontend/src/proprietary/components/shared/config/configSections/audit/AuditExportSection.tsx @@ -6,6 +6,7 @@ import { Stack, Button, SegmentedControl, + Checkbox, } from '@mantine/core'; import { useTranslation } from 'react-i18next'; import auditService from '@app/services/auditService'; @@ -15,12 +16,31 @@ import AuditFiltersForm from '@app/components/shared/config/configSections/audit interface AuditExportSectionProps { loginEnabled?: boolean; + captureFileHash?: boolean; + capturePdfAuthor?: boolean; + captureOperationResults?: boolean; } -const AuditExportSection: React.FC = ({ loginEnabled = true }) => { +const AuditExportSection: React.FC = ({ + loginEnabled = true, + captureFileHash = false, + capturePdfAuthor = false, + captureOperationResults = false, +}) => { const { t } = useTranslation(); const [exportFormat, setExportFormat] = useState<'csv' | 'json'>('csv'); const [exporting, setExporting] = useState(false); + const [selectedFields, setSelectedFields] = useState>({ + date: true, + username: true, + ipaddress: false, + tool: true, + documentName: true, + outcome: true, + author: capturePdfAuthor, + fileHash: captureFileHash, + operationResults: captureOperationResults, + }); // Use shared filters hook const { filters, eventTypes, users, handleFilterChange, handleClearFilters } = useAuditFilters({}, loginEnabled); @@ -31,7 +51,9 @@ const AuditExportSection: React.FC = ({ loginEnabled = try { setExporting(true); - const blob = await auditService.exportData(exportFormat, filters); + const fieldsParam = Object.keys(selectedFields).filter(k => selectedFields[k as keyof typeof selectedFields]).join(','); + + const blob = await auditService.exportData(exportFormat, { ...filters, fields: fieldsParam }); // Create download link const url = window.URL.createObjectURL(blob); @@ -83,6 +105,75 @@ const AuditExportSection: React.FC = ({ loginEnabled = /> + {/* Field Selection */} +
+ + {t('audit.export.selectFields', 'Select Fields to Include')} + + + setSelectedFields({ ...selectedFields, date: e.currentTarget.checked })} + disabled={!loginEnabled} + /> + setSelectedFields({ ...selectedFields, username: e.currentTarget.checked })} + disabled={!loginEnabled} + /> + setSelectedFields({ ...selectedFields, ipaddress: e.currentTarget.checked })} + disabled={!loginEnabled} + /> + setSelectedFields({ ...selectedFields, tool: e.currentTarget.checked })} + disabled={!loginEnabled} + /> + setSelectedFields({ ...selectedFields, documentName: e.currentTarget.checked })} + disabled={!loginEnabled} + /> + setSelectedFields({ ...selectedFields, outcome: e.currentTarget.checked })} + disabled={!loginEnabled} + /> + {capturePdfAuthor && ( + setSelectedFields({ ...selectedFields, author: e.currentTarget.checked })} + disabled={!loginEnabled} + /> + )} + {captureFileHash && ( + setSelectedFields({ ...selectedFields, fileHash: e.currentTarget.checked })} + disabled={!loginEnabled} + /> + )} + {captureOperationResults && ( + setSelectedFields({ ...selectedFields, operationResults: e.currentTarget.checked })} + disabled={!loginEnabled} + /> + )} + +
+ {/* Filters */}
diff --git a/frontend/src/proprietary/components/shared/config/configSections/audit/AuditFiltersForm.tsx b/frontend/src/proprietary/components/shared/config/configSections/audit/AuditFiltersForm.tsx index 7d90ff9eea..13c02081b2 100644 --- a/frontend/src/proprietary/components/shared/config/configSections/audit/AuditFiltersForm.tsx +++ b/frontend/src/proprietary/components/shared/config/configSections/audit/AuditFiltersForm.tsx @@ -1,10 +1,44 @@ import React from 'react'; -import { Group, Select, Button } from '@mantine/core'; +import { Group, MultiSelect, Button, Stack, SimpleGrid, Text } from '@mantine/core'; import { DateInput } from '@mantine/dates'; import { useTranslation } from 'react-i18next'; import { AuditFilters } from '@app/services/auditService'; import { Z_INDEX_OVER_CONFIG_MODAL } from '@app/styles/zIndex'; +// Helper to format date as YYYY-MM-DD in local time (avoids DST/UTC issues) +const formatDateToYMD = (date: Date): string => { + const year = date.getFullYear(); + const month = String(date.getMonth() + 1).padStart(2, '0'); + const day = String(date.getDate()).padStart(2, '0'); + return `${year}-${month}-${day}`; +}; + +// Helper to calculate date range for quick presets +const getDateRange = (preset: string): [Date, Date] | null => { + const end = new Date(); + const start = new Date(); + + switch (preset) { + case 'today': + start.setHours(0, 0, 0, 0); + end.setHours(23, 59, 59, 999); + return [start, end]; + case 'last7': + start.setDate(start.getDate() - 6); + return [start, end]; + case 'last30': + start.setDate(start.getDate() - 29); + return [start, end]; + case 'thisMonth': + start.setDate(1); + start.setHours(0, 0, 0, 0); + end.setHours(23, 59, 59, 999); + return [start, end]; + default: + return null; + } +}; + interface AuditFiltersFormProps { filters: AuditFilters; eventTypes: string[]; @@ -27,55 +61,118 @@ const AuditFiltersForm: React.FC = ({ }) => { const { t } = useTranslation(); + const handleQuickPreset = (preset: string) => { + const range = getDateRange(preset); + if (range) { + const [start, end] = range; + onFilterChange('startDate', formatDateToYMD(start)); + onFilterChange('endDate', formatDateToYMD(end)); + } + }; + + const isPresetActive = (preset: string): boolean => { + if (!filters.startDate || !filters.endDate) return false; + const range = getDateRange(preset); + if (!range) return false; + const [expectedStart, expectedEnd] = range; + const expectedStartStr = formatDateToYMD(expectedStart); + const expectedEndStr = formatDateToYMD(expectedEnd); + return filters.startDate === expectedStartStr && filters.endDate === expectedEndStr; + }; + return ( - - ({ value: user, label: user }))} - value={filters.username} - onChange={(value) => onFilterChange('username', value || undefined)} - clearable - searchable - disabled={disabled} - style={{ flex: 1, minWidth: 200 }} - comboboxProps={{ withinPortal: true, zIndex: Z_INDEX_OVER_CONFIG_MODAL }} - /> - - onFilterChange('startDate', value ?? undefined) - } - clearable - disabled={disabled} - style={{ flex: 1, minWidth: 150 }} - popoverProps={{ withinPortal: true, zIndex: Z_INDEX_OVER_CONFIG_MODAL }} - /> - - onFilterChange('endDate', value ?? undefined) - } - clearable - disabled={disabled} - style={{ flex: 1, minWidth: 150 }} - popoverProps={{ withinPortal: true, zIndex: Z_INDEX_OVER_CONFIG_MODAL }} - /> - - + + {/* Quick Preset Buttons */} +
+ + {t('audit.filters.quickPresets', 'Quick filters')} + + + + + + + +
+ + {/* Filter Inputs */} + + ({ value: type, label: type }))} + value={Array.isArray(filters.eventType) ? filters.eventType : (filters.eventType ? [filters.eventType] : [])} + onChange={(value) => onFilterChange('eventType', value.length > 0 ? value : undefined)} + clearable + disabled={disabled} + comboboxProps={{ withinPortal: true, zIndex: Z_INDEX_OVER_CONFIG_MODAL }} + /> + ({ value: user, label: user }))} + value={Array.isArray(filters.username) ? filters.username : (filters.username ? [filters.username] : [])} + onChange={(value) => onFilterChange('username', value.length > 0 ? value : undefined)} + clearable + searchable + disabled={disabled} + comboboxProps={{ withinPortal: true, zIndex: Z_INDEX_OVER_CONFIG_MODAL }} + /> + { + onFilterChange('startDate', value ? formatDateToYMD(value as Date) : undefined); + }} + clearable + disabled={disabled} + popoverProps={{ withinPortal: true, zIndex: Z_INDEX_OVER_CONFIG_MODAL }} + /> + { + onFilterChange('endDate', value ? formatDateToYMD(value as Date) : undefined); + }} + clearable + disabled={disabled} + popoverProps={{ withinPortal: true, zIndex: Z_INDEX_OVER_CONFIG_MODAL }} + /> + + + {/* Clear Button */} + + + +
); }; diff --git a/frontend/src/proprietary/components/shared/config/configSections/audit/AuditStatsCards.tsx b/frontend/src/proprietary/components/shared/config/configSections/audit/AuditStatsCards.tsx new file mode 100644 index 0000000000..1b232d02a4 --- /dev/null +++ b/frontend/src/proprietary/components/shared/config/configSections/audit/AuditStatsCards.tsx @@ -0,0 +1,232 @@ +import React, { useState, useEffect } from 'react'; +import { Card, Group, Stack, Text, Badge, SimpleGrid, Loader, Alert } from '@mantine/core'; +import { useTranslation } from 'react-i18next'; +import auditService, { AuditStats } from '@app/services/auditService'; +import LocalIcon from '@app/components/shared/LocalIcon'; + +interface AuditStatsCardsProps { + loginEnabled?: boolean; + timePeriod: 'day' | 'week' | 'month'; +} + +const AuditStatsCards: React.FC = ({ loginEnabled = true, timePeriod = 'week' }) => { + const { t } = useTranslation(); + const [stats, setStats] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + useEffect(() => { + const fetchStats = async () => { + try { + setLoading(true); + setError(null); + const data = await auditService.getStats(timePeriod); + setStats(data); + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to load statistics'); + } finally { + setLoading(false); + } + }; + + if (loginEnabled) { + fetchStats(); + } else { + // Demo data when login disabled + setStats({ + totalEvents: 4256, + prevTotalEvents: 3891, + uniqueUsers: 12, + prevUniqueUsers: 10, + successRate: 96.5, + prevSuccessRate: 94.2, + avgLatencyMs: 342, + prevAvgLatencyMs: 385, + errorCount: 148, + topEventType: 'PDF_PROCESS', + topUser: 'admin', + eventsByType: {}, + eventsByUser: {}, + topTools: {}, + hourlyDistribution: {}, + }); + setLoading(false); + } + }, [timePeriod, loginEnabled]); + + if (loading) { + return ( + +
+ +
+
+ ); + } + + if (error) { + return ( + + {error} + + ); + } + + if (!stats) { + return null; + } + + const trendPercent = stats.prevTotalEvents > 0 ? ((stats.totalEvents - stats.prevTotalEvents) / stats.prevTotalEvents) * 100 : 0; + const userTrend = stats.prevUniqueUsers > 0 ? ((stats.uniqueUsers - stats.prevUniqueUsers) / stats.prevUniqueUsers) * 100 : 0; + const latencyTrend = stats.prevAvgLatencyMs > 0 ? ((stats.avgLatencyMs - stats.prevAvgLatencyMs) / stats.prevAvgLatencyMs) * 100 : 0; + const successTrend = stats.prevSuccessRate > 0 ? stats.successRate - stats.prevSuccessRate : 0; + + const getSuccessRateColor = (rate: number) => { + if (rate >= 95) return 'green'; + if (rate >= 80) return 'yellow'; + return 'red'; + }; + + const getTrendColor = (trend: number, lowerIsBetter: boolean = false) => { + if (lowerIsBetter) { + return trend <= 0 ? 'green' : 'red'; + } + return trend >= 0 ? 'green' : 'red'; + }; + + const getTrendIcon = (trend: number, lowerIsBetter: boolean = false) => { + const isPositive = lowerIsBetter ? trend <= 0 : trend >= 0; + return isPositive ? 'trending-up' : 'trending-down'; + }; + + return ( + + {/* Total Events Card */} + + + + + {t('audit.stats.totalEvents', 'Total Events')} + + + + + {stats.totalEvents.toLocaleString()} + + {trendPercent !== 0 && ( + + } + > + {Math.abs(trendPercent).toFixed(1)}% {t('audit.stats.vsLastPeriod', 'vs last period')} + + )} + + + + {/* Success Rate Card */} + + + + + {t('audit.stats.successRate', 'Success Rate')} + + + + + {stats.successRate.toFixed(1)}% + + + + {stats.successRate >= 95 + ? t('audit.stats.excellent', 'Excellent') + : stats.successRate >= 80 + ? t('audit.stats.good', 'Good') + : t('audit.stats.attention', 'Attention needed')} + + {successTrend !== 0 && ( + + {successTrend > 0 ? '+' : ''}{successTrend.toFixed(1)}% + + )} + + + + + {/* Active Users Card */} + + + + + {t('audit.stats.activeUsers', 'Active Users')} + + + + + {stats.uniqueUsers} + + {userTrend !== 0 && ( + + } + > + {Math.abs(userTrend).toFixed(1)}% + + )} + + + + {/* Avg Latency Card */} + + + + + {t('audit.stats.avgLatency', 'Avg Latency')} + + + + + {stats.avgLatencyMs > 0 ? `${stats.avgLatencyMs.toFixed(0)}ms` : t('audit.stats.noData', 'N/A')} + + {latencyTrend !== 0 && stats.avgLatencyMs > 0 && ( + + } + > + {Math.abs(latencyTrend).toFixed(1)}% + + )} + + + + ); +}; + +export default AuditStatsCards; diff --git a/frontend/src/proprietary/components/shared/config/configSections/audit/AuditSystemStatus.tsx b/frontend/src/proprietary/components/shared/config/configSections/audit/AuditSystemStatus.tsx index c171092530..8063fe9f68 100644 --- a/frontend/src/proprietary/components/shared/config/configSections/audit/AuditSystemStatus.tsx +++ b/frontend/src/proprietary/components/shared/config/configSections/audit/AuditSystemStatus.tsx @@ -1,5 +1,5 @@ import React from 'react'; -import { Card, Group, Stack, Badge, Text } from '@mantine/core'; +import { Card, Group, Stack, Badge, Text, Divider } from '@mantine/core'; import { useTranslation } from 'react-i18next'; import { AuditSystemStatus as AuditStatus } from '@app/services/auditService'; @@ -56,6 +56,44 @@ const AuditSystemStatus: React.FC = ({ status }) => {
+ + + +
+ + {t('audit.systemStatus.capturedFields', 'Captured Fields')} + + + + {t('audit.systemStatus.username', 'Username')} + + + {t('audit.systemStatus.documentName', 'Document Name')} + + + {t('audit.systemStatus.tool', 'Tool')} + + + {t('audit.systemStatus.date', 'Date')} + + + {t('audit.systemStatus.pdfAuthor', 'PDF Author')} + {!status.capturePdfAuthor && ( + + ({t('audit.systemStatus.captureBySettings', 'Enable in settings')}) + + )} + + + {t('audit.systemStatus.fileHash', 'File Hash')} + {!status.captureFileHash && ( + + ({t('audit.systemStatus.captureBySettings', 'Enable in settings')}) + + )} + + +
);