mirror of
https://github.com/arsvendg/Stirling-PDF.git
synced 2026-07-16 19:33:11 +02:00
Audit fixes and improvements (#5835)
This commit is contained in:
@@ -2,12 +2,14 @@ package stirling.software.common.aop;
|
|||||||
|
|
||||||
import java.io.IOException;
|
import java.io.IOException;
|
||||||
import java.time.Duration;
|
import java.time.Duration;
|
||||||
import java.util.concurrent.CompletableFuture;
|
import java.util.HashMap;
|
||||||
import java.util.concurrent.TimeUnit;
|
import java.util.Map;
|
||||||
import java.util.concurrent.atomic.AtomicReference;
|
import java.util.concurrent.atomic.AtomicReference;
|
||||||
|
import java.util.function.Supplier;
|
||||||
|
|
||||||
import org.aspectj.lang.ProceedingJoinPoint;
|
import org.aspectj.lang.ProceedingJoinPoint;
|
||||||
import org.aspectj.lang.annotation.*;
|
import org.aspectj.lang.annotation.*;
|
||||||
|
import org.slf4j.MDC;
|
||||||
import org.springframework.core.annotation.Order;
|
import org.springframework.core.annotation.Order;
|
||||||
import org.springframework.stereotype.Component;
|
import org.springframework.stereotype.Component;
|
||||||
import org.springframework.web.multipart.MultipartFile;
|
import org.springframework.web.multipart.MultipartFile;
|
||||||
@@ -26,7 +28,7 @@ import stirling.software.common.service.JobExecutorService;
|
|||||||
@Component
|
@Component
|
||||||
@RequiredArgsConstructor
|
@RequiredArgsConstructor
|
||||||
@Slf4j
|
@Slf4j
|
||||||
@Order(0) // Highest precedence - executes before audit aspects
|
@Order(20) // Lower precedence - executes AFTER audit aspects populate MDC
|
||||||
public class AutoJobAspect {
|
public class AutoJobAspect {
|
||||||
|
|
||||||
private static final Duration RETRY_BASE_DELAY = Duration.ofMillis(100);
|
private static final Duration RETRY_BASE_DELAY = Duration.ofMillis(100);
|
||||||
@@ -70,26 +72,29 @@ public class AutoJobAspect {
|
|||||||
// No retries needed, simple execution
|
// No retries needed, simple execution
|
||||||
return jobExecutorService.runJobGeneric(
|
return jobExecutorService.runJobGeneric(
|
||||||
async,
|
async,
|
||||||
() -> {
|
wrapWithMDC(
|
||||||
try {
|
() -> {
|
||||||
// Note: Progress tracking is handled in TaskManager/JobExecutorService
|
try {
|
||||||
// The trackProgress flag controls whether detailed progress is stored
|
// Note: Progress tracking is handled in
|
||||||
// for REST API queries, not WebSocket notifications
|
// TaskManager/JobExecutorService
|
||||||
return joinPoint.proceed(args);
|
// The trackProgress flag controls whether detailed progress is
|
||||||
} catch (Throwable ex) {
|
// stored
|
||||||
log.error(
|
// for REST API queries, not WebSocket notifications
|
||||||
"AutoJobAspect caught exception during job execution: {}",
|
return joinPoint.proceed(args);
|
||||||
ex.getMessage(),
|
} catch (Throwable ex) {
|
||||||
ex);
|
log.error(
|
||||||
// Rethrow RuntimeException as-is to preserve exception type
|
"AutoJobAspect caught exception during job execution: {}",
|
||||||
if (ex instanceof RuntimeException) {
|
ex.getMessage(),
|
||||||
throw (RuntimeException) ex;
|
ex);
|
||||||
}
|
// Rethrow RuntimeException as-is to preserve exception type
|
||||||
// Wrap checked exceptions - GlobalExceptionHandler will unwrap
|
if (ex instanceof RuntimeException) {
|
||||||
// BaseAppException
|
throw (RuntimeException) ex;
|
||||||
throw new RuntimeException(ex);
|
}
|
||||||
}
|
// Wrap checked exceptions - GlobalExceptionHandler will unwrap
|
||||||
},
|
// BaseAppException
|
||||||
|
throw new RuntimeException(ex);
|
||||||
|
}
|
||||||
|
}),
|
||||||
timeout,
|
timeout,
|
||||||
queueable,
|
queueable,
|
||||||
resourceWeight);
|
resourceWeight);
|
||||||
@@ -123,114 +128,108 @@ public class AutoJobAspect {
|
|||||||
|
|
||||||
return jobExecutorService.runJobGeneric(
|
return jobExecutorService.runJobGeneric(
|
||||||
async,
|
async,
|
||||||
() -> {
|
wrapWithMDC(
|
||||||
// Use iterative approach instead of recursion to avoid stack overflow
|
() -> {
|
||||||
Throwable lastException = null;
|
// Use iterative approach instead of recursion to avoid stack overflow
|
||||||
|
Throwable lastException = null;
|
||||||
|
|
||||||
// Attempt counter starts at 1 for first try
|
// Attempt counter starts at 1 for first try
|
||||||
for (int currentAttempt = 1; currentAttempt <= maxRetries; currentAttempt++) {
|
for (int currentAttempt = 1;
|
||||||
try {
|
currentAttempt <= maxRetries;
|
||||||
if (trackProgress && async) {
|
currentAttempt++) {
|
||||||
// Get jobId for progress tracking in TaskManager
|
try {
|
||||||
// This enables REST API progress queries, not WebSocket
|
if (trackProgress && async) {
|
||||||
if (jobIdRef.get() == null) {
|
// Get jobId for progress tracking in TaskManager
|
||||||
jobIdRef.set(getJobIdFromContext());
|
// This enables REST API progress queries, not WebSocket
|
||||||
}
|
if (jobIdRef.get() == null) {
|
||||||
String jobId = jobIdRef.get();
|
jobIdRef.set(getJobIdFromContext());
|
||||||
if (jobId != null) {
|
}
|
||||||
log.debug(
|
String jobId = jobIdRef.get();
|
||||||
"Tracking progress for job {} (attempt {}/{})",
|
if (jobId != null) {
|
||||||
jobId,
|
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,
|
currentAttempt,
|
||||||
maxRetries);
|
maxRetries,
|
||||||
// Progress is tracked in TaskManager for REST API access
|
ex.getMessage(),
|
||||||
// No WebSocket notifications sent here
|
ex);
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Attempt to execute the operation
|
// Check if we should retry
|
||||||
return joinPoint.proceed(args);
|
if (currentAttempt < maxRetries) {
|
||||||
|
log.info(
|
||||||
|
"Retrying operation, attempt {}/{}",
|
||||||
|
currentAttempt + 1,
|
||||||
|
maxRetries);
|
||||||
|
|
||||||
} catch (Throwable ex) {
|
if (trackProgress && async) {
|
||||||
lastException = ex;
|
String jobId = jobIdRef.get();
|
||||||
log.error(
|
if (jobId != null) {
|
||||||
"AutoJobAspect caught exception during job execution (attempt"
|
log.debug(
|
||||||
+ " {}/{}): {}",
|
"Recording retry attempt for job {} in TaskManager",
|
||||||
currentAttempt,
|
jobId);
|
||||||
maxRetries,
|
// Retry info is tracked in TaskManager for REST API
|
||||||
ex.getMessage(),
|
// access
|
||||||
ex);
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Check if we should retry
|
// Use sleep for retry delay
|
||||||
if (currentAttempt < maxRetries) {
|
// For sync jobs, both sleep and async are blocking at this
|
||||||
log.info(
|
// point
|
||||||
"Retrying operation, attempt {}/{}",
|
// For async jobs, the delay occurs in the executor thread
|
||||||
currentAttempt + 1,
|
long delayMs = RETRY_BASE_DELAY.toMillis() * currentAttempt;
|
||||||
maxRetries);
|
|
||||||
|
|
||||||
if (trackProgress && async) {
|
try {
|
||||||
String jobId = jobIdRef.get();
|
Thread.sleep(delayMs);
|
||||||
if (jobId != null) {
|
} catch (InterruptedException e) {
|
||||||
log.debug(
|
Thread.currentThread().interrupt();
|
||||||
"Recording retry attempt for job {} in TaskManager",
|
log.debug(
|
||||||
jobId);
|
"Retry delay interrupted for attempt {}/{}",
|
||||||
// Retry info is tracked in TaskManager for REST API access
|
currentAttempt,
|
||||||
|
maxRetries);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// No more retries, we'll throw the exception after the loop
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Use non-blocking delay for all retry attempts to avoid blocking
|
|
||||||
// threads
|
|
||||||
// For sync jobs this avoids starving the tomcat thread pool under
|
|
||||||
// load
|
|
||||||
long delayMs = RETRY_BASE_DELAY.toMillis() * currentAttempt;
|
|
||||||
|
|
||||||
// Execute the retry after a delay through the JobExecutorService
|
|
||||||
// rather than blocking the current thread with sleep
|
|
||||||
CompletableFuture<Object> delayedRetry = new CompletableFuture<>();
|
|
||||||
|
|
||||||
// Use a delayed executor for non-blocking delay
|
|
||||||
CompletableFuture.delayedExecutor(delayMs, TimeUnit.MILLISECONDS)
|
|
||||||
.execute(
|
|
||||||
() -> {
|
|
||||||
// Continue the retry loop in the next iteration
|
|
||||||
// We can't return from here directly since
|
|
||||||
// we're in a Runnable
|
|
||||||
delayedRetry.complete(null);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Wait for the delay to complete before continuing
|
|
||||||
try {
|
|
||||||
delayedRetry.join();
|
|
||||||
} catch (Exception e) {
|
|
||||||
Thread.currentThread().interrupt();
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// No more retries, we'll throw the exception after the loop
|
|
||||||
break;
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// If we get here, all retries failed
|
// If we get here, all retries failed
|
||||||
if (lastException != null) {
|
if (lastException != null) {
|
||||||
// Rethrow RuntimeException as-is to preserve exception type
|
// Rethrow RuntimeException as-is to preserve exception type
|
||||||
if (lastException instanceof RuntimeException) {
|
if (lastException instanceof RuntimeException) {
|
||||||
throw (RuntimeException) lastException;
|
throw (RuntimeException) lastException;
|
||||||
}
|
}
|
||||||
// Wrap checked exceptions - GlobalExceptionHandler will unwrap
|
// Wrap checked exceptions - GlobalExceptionHandler will unwrap
|
||||||
// BaseAppException
|
// BaseAppException
|
||||||
throw new RuntimeException(
|
throw new RuntimeException(
|
||||||
"Job failed after "
|
"Job failed after "
|
||||||
+ maxRetries
|
+ maxRetries
|
||||||
+ " attempts: "
|
+ " attempts: "
|
||||||
+ lastException.getMessage(),
|
+ lastException.getMessage(),
|
||||||
lastException);
|
lastException);
|
||||||
}
|
}
|
||||||
|
|
||||||
// This should never happen if lastException is properly tracked
|
// This should never happen if lastException is properly tracked
|
||||||
throw new RuntimeException("Job failed but no exception was recorded");
|
throw new RuntimeException("Job failed but no exception was recorded");
|
||||||
},
|
}),
|
||||||
timeout,
|
timeout,
|
||||||
queueable,
|
queueable,
|
||||||
resourceWeight);
|
resourceWeight);
|
||||||
@@ -299,4 +298,32 @@ public class AutoJobAspect {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wraps a supplier to propagate MDC context to background threads. Captures MDC on request
|
||||||
|
* thread and restores it in the background thread. Ensures proper cleanup to prevent context
|
||||||
|
* leakage across jobs in thread pools.
|
||||||
|
*/
|
||||||
|
private <T> Supplier<T> wrapWithMDC(Supplier<T> supplier) {
|
||||||
|
final Map<String, String> captured = MDC.getCopyOfContextMap();
|
||||||
|
return () -> {
|
||||||
|
final Map<String, String> previous = MDC.getCopyOfContextMap();
|
||||||
|
try {
|
||||||
|
// Set the captured context (or clear if none was captured)
|
||||||
|
if (captured != null) {
|
||||||
|
MDC.setContextMap(new HashMap<>(captured));
|
||||||
|
} else {
|
||||||
|
MDC.clear();
|
||||||
|
}
|
||||||
|
return supplier.get();
|
||||||
|
} finally {
|
||||||
|
// Restore previous state (or clear if there was none)
|
||||||
|
if (previous != null) {
|
||||||
|
MDC.setContextMap(previous);
|
||||||
|
} else {
|
||||||
|
MDC.clear();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -696,7 +696,8 @@ public class ApplicationProperties {
|
|||||||
|
|
||||||
@Override
|
@Override
|
||||||
public String toString() {
|
public String toString() {
|
||||||
return """
|
return
|
||||||
|
"""
|
||||||
Driver {
|
Driver {
|
||||||
driverName='%s'
|
driverName='%s'
|
||||||
}
|
}
|
||||||
@@ -960,6 +961,12 @@ public class ApplicationProperties {
|
|||||||
private boolean enabled = true;
|
private boolean enabled = true;
|
||||||
private int level = 2; // 0=OFF, 1=BASIC, 2=STANDARD, 3=VERBOSE
|
private int level = 2; // 0=OFF, 1=BASIC, 2=STANDARD, 3=VERBOSE
|
||||||
private int retentionDays = 90;
|
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
|
@Data
|
||||||
|
|||||||
@@ -169,7 +169,10 @@ public class PdfMetadataService {
|
|||||||
.getAuthor();
|
.getAuthor();
|
||||||
|
|
||||||
if (userService != null) {
|
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);
|
pdf.getDocumentInformation().setAuthor(author);
|
||||||
|
|||||||
@@ -99,6 +99,8 @@ dependencies {
|
|||||||
// Batik only bridge module needed (transitively pulls anim, gvt, util, css, dom, svg-dom)
|
// 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
|
// Replaces batik-all which included unused codec, svggen, transcoder, script modules
|
||||||
implementation 'org.apache.xmlgraphics:batik-bridge:1.19'
|
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
|
// PDFBox Graphics2D bridge for Batik SVG to PDF conversion
|
||||||
implementation 'de.rototor.pdfbox:graphics2d:3.0.5'
|
implementation 'de.rototor.pdfbox:graphics2d:3.0.5'
|
||||||
|
|||||||
@@ -98,9 +98,12 @@ premium:
|
|||||||
producer: Stirling-PDF
|
producer: Stirling-PDF
|
||||||
enterpriseFeatures:
|
enterpriseFeatures:
|
||||||
audit:
|
audit:
|
||||||
enabled: true # Enable audit logging
|
enabled: true # Enable audit logging for security and compliance tracking
|
||||||
level: 2 # Audit logging level: 0=OFF, 1=BASIC, 2=STANDARD, 3=VERBOSE
|
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
|
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:
|
databaseNotifications:
|
||||||
backups:
|
backups:
|
||||||
successful: false # set to 'true' to enable email notifications for successful database backups
|
successful: false # set to 'true' to enable email notifications for successful database backups
|
||||||
|
|||||||
+71
-26
@@ -8,6 +8,7 @@ import org.aspectj.lang.ProceedingJoinPoint;
|
|||||||
import org.aspectj.lang.annotation.Around;
|
import org.aspectj.lang.annotation.Around;
|
||||||
import org.aspectj.lang.annotation.Aspect;
|
import org.aspectj.lang.annotation.Aspect;
|
||||||
import org.aspectj.lang.reflect.MethodSignature;
|
import org.aspectj.lang.reflect.MethodSignature;
|
||||||
|
import org.slf4j.MDC;
|
||||||
import org.springframework.stereotype.Component;
|
import org.springframework.stereotype.Component;
|
||||||
import org.springframework.web.context.request.RequestContextHolder;
|
import org.springframework.web.context.request.RequestContextHolder;
|
||||||
import org.springframework.web.context.request.ServletRequestAttributes;
|
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
|
// Fast path: use unified check to determine if we should audit
|
||||||
// This avoids all data collection if auditing is disabled
|
// This avoids all data collection if auditing is disabled
|
||||||
if (!AuditUtils.shouldAudit(method, auditConfig)) {
|
if (!auditService.shouldAudit(method, auditConfig)) {
|
||||||
return joinPoint.proceed();
|
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
|
// Only create the map once we know we'll use it
|
||||||
Map<String, Object> auditData =
|
Map<String, Object> auditData =
|
||||||
AuditUtils.createBaseAuditData(joinPoint, auditedAnnotation.level());
|
auditService.createBaseAuditData(joinPoint, auditedAnnotation.level());
|
||||||
|
|
||||||
// Add HTTP information if we're in a web context
|
// Add HTTP information if we're in a web context
|
||||||
ServletRequestAttributes attrs =
|
|
||||||
(ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
|
|
||||||
if (attrs != null) {
|
if (attrs != null) {
|
||||||
HttpServletRequest req = attrs.getRequest();
|
|
||||||
String path = req.getRequestURI();
|
String path = req.getRequestURI();
|
||||||
String httpMethod = req.getMethod();
|
String httpMethod = req.getMethod();
|
||||||
AuditUtils.addHttpData(auditData, httpMethod, path, auditedAnnotation.level());
|
auditService.addHttpData(auditData, httpMethod, path, auditedAnnotation.level());
|
||||||
AuditUtils.addFileData(auditData, joinPoint, 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
|
// Add method arguments if requested (captured at all audit levels for operational context)
|
||||||
boolean includeArgs =
|
if (auditedAnnotation.includeArgs()) {
|
||||||
auditedAnnotation.includeArgs()
|
auditService.addMethodArguments(auditData, joinPoint, auditedAnnotation.level());
|
||||||
&& (auditedAnnotation.level() == AuditLevel.VERBOSE
|
|
||||||
|| auditConfig.getAuditLevel() == AuditLevel.VERBOSE);
|
|
||||||
|
|
||||||
if (includeArgs) {
|
|
||||||
AuditUtils.addMethodArguments(auditData, joinPoint, AuditLevel.VERBOSE);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Record start time for latency calculation
|
// Record start time for latency calculation
|
||||||
@@ -80,15 +115,14 @@ public class AuditAspect {
|
|||||||
// Add success status
|
// Add success status
|
||||||
auditData.put("status", "success");
|
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 =
|
boolean includeResult =
|
||||||
auditedAnnotation.includeResult()
|
auditedAnnotation.includeResult()
|
||||||
&& (auditedAnnotation.level() == AuditLevel.VERBOSE
|
&& auditService.shouldCaptureOperationResults();
|
||||||
|| auditConfig.getAuditLevel() == AuditLevel.VERBOSE);
|
|
||||||
|
|
||||||
if (includeResult && result != null) {
|
if (includeResult && result != null) {
|
||||||
// Use safe string conversion with size limiting
|
// Use safe string conversion with size limiting
|
||||||
auditData.put("result", AuditUtils.safeToString(result, 1000));
|
auditData.put("result", auditService.safeToString(result, 1000));
|
||||||
}
|
}
|
||||||
|
|
||||||
return result;
|
return result;
|
||||||
@@ -105,20 +139,19 @@ public class AuditAspect {
|
|||||||
// methods
|
// methods
|
||||||
HttpServletResponse resp = attrs != null ? attrs.getResponse() : null;
|
HttpServletResponse resp = attrs != null ? attrs.getResponse() : null;
|
||||||
boolean isHttpRequest = attrs != null;
|
boolean isHttpRequest = attrs != null;
|
||||||
AuditUtils.addTimingData(
|
auditService.addTimingData(
|
||||||
auditData, startTime, resp, auditedAnnotation.level(), isHttpRequest);
|
auditData, startTime, resp, auditedAnnotation.level(), isHttpRequest);
|
||||||
|
|
||||||
// Resolve the event type based on annotation and context
|
// Resolve the event type based on annotation and context
|
||||||
String httpMethod = null;
|
String httpMethod = null;
|
||||||
String path = null;
|
String path = null;
|
||||||
if (attrs != null) {
|
if (attrs != null) {
|
||||||
HttpServletRequest req = attrs.getRequest();
|
|
||||||
httpMethod = req.getMethod();
|
httpMethod = req.getMethod();
|
||||||
path = req.getRequestURI();
|
path = req.getRequestURI();
|
||||||
}
|
}
|
||||||
|
|
||||||
AuditEventType eventType =
|
AuditEventType eventType =
|
||||||
AuditUtils.resolveEventType(
|
auditService.resolveEventType(
|
||||||
method,
|
method,
|
||||||
joinPoint.getTarget().getClass(),
|
joinPoint.getTarget().getClass(),
|
||||||
path,
|
path,
|
||||||
@@ -128,11 +161,23 @@ public class AuditAspect {
|
|||||||
// Check if we should use string type instead
|
// Check if we should use string type instead
|
||||||
String typeString = auditedAnnotation.typeString();
|
String typeString = auditedAnnotation.typeString();
|
||||||
if (eventType == AuditEventType.HTTP_REQUEST && StringUtils.isNotEmpty(typeString)) {
|
if (eventType == AuditEventType.HTTP_REQUEST && StringUtils.isNotEmpty(typeString)) {
|
||||||
// Use the string type (for backward compatibility)
|
// Use the string type with early-captured values
|
||||||
auditService.audit(typeString, auditData, auditedAnnotation.level());
|
auditService.audit(
|
||||||
|
capturedPrincipal,
|
||||||
|
capturedOrigin,
|
||||||
|
capturedIp,
|
||||||
|
typeString,
|
||||||
|
auditData,
|
||||||
|
auditedAnnotation.level());
|
||||||
} else {
|
} else {
|
||||||
// Use the enum type (preferred)
|
// Use the enum type with early-captured values
|
||||||
auditService.audit(eventType, auditData, auditedAnnotation.level());
|
auditService.audit(
|
||||||
|
capturedPrincipal,
|
||||||
|
capturedOrigin,
|
||||||
|
capturedIp,
|
||||||
|
eventType,
|
||||||
|
auditData,
|
||||||
|
auditedAnnotation.level());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,6 +22,9 @@ public enum AuditEventType {
|
|||||||
// PDF operations - STANDARD level
|
// PDF operations - STANDARD level
|
||||||
PDF_PROCESS("PDF processing operation"),
|
PDF_PROCESS("PDF processing operation"),
|
||||||
|
|
||||||
|
// UI data requests - STANDARD level
|
||||||
|
UI_DATA("UI data request"),
|
||||||
|
|
||||||
// HTTP requests - STANDARD level
|
// HTTP requests - STANDARD level
|
||||||
HTTP_REQUEST("HTTP request");
|
HTTP_REQUEST("HTTP request");
|
||||||
|
|
||||||
|
|||||||
@@ -12,21 +12,26 @@ public enum AuditLevel {
|
|||||||
OFF(0),
|
OFF(0),
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* BASIC - Minimal audit logging (level 1) Includes: - Authentication events (login, logout,
|
* BASIC - File modifications only (level 1) Tracks: PDF file operations like compress, split,
|
||||||
* failed logins) - Password changes - User/role changes - System configuration changes
|
* 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),
|
BASIC(1),
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* STANDARD - Standard audit logging (level 2) Includes everything in BASIC plus: - All HTTP
|
* STANDARD - File operations and user actions (level 2) Tracks: Everything in BASIC plus user
|
||||||
* requests (basic info: URL, method, status) - File operations (upload, download, process) -
|
* actions like login/logout, account changes, and general GET requests. Excludes continuous
|
||||||
* PDF operations (view, edit, etc.) - User operations
|
* 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),
|
STANDARD(2),
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* VERBOSE - Detailed audit logging (level 3) Includes everything in STANDARD plus: - Request
|
* VERBOSE - Everything including polling (level 3) Tracks: Everything in STANDARD plus
|
||||||
* headers and parameters - Method parameters - Operation results - Detailed timing information
|
* 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);
|
VERBOSE(3);
|
||||||
|
|
||||||
|
|||||||
@@ -1,427 +0,0 @@
|
|||||||
package stirling.software.proprietary.audit;
|
|
||||||
|
|
||||||
import java.lang.reflect.Method;
|
|
||||||
import java.time.Instant;
|
|
||||||
import java.util.Arrays;
|
|
||||||
import java.util.HashMap;
|
|
||||||
import java.util.List;
|
|
||||||
import java.util.Locale;
|
|
||||||
import java.util.Map;
|
|
||||||
import java.util.stream.Collectors;
|
|
||||||
import java.util.stream.IntStream;
|
|
||||||
|
|
||||||
import org.apache.commons.lang3.StringUtils;
|
|
||||||
import org.aspectj.lang.ProceedingJoinPoint;
|
|
||||||
import org.aspectj.lang.reflect.MethodSignature;
|
|
||||||
import org.slf4j.MDC;
|
|
||||||
import org.springframework.http.MediaType;
|
|
||||||
import org.springframework.security.core.Authentication;
|
|
||||||
import org.springframework.security.core.context.SecurityContextHolder;
|
|
||||||
import org.springframework.web.context.request.RequestContextHolder;
|
|
||||||
import org.springframework.web.context.request.ServletRequestAttributes;
|
|
||||||
import org.springframework.web.multipart.MultipartFile;
|
|
||||||
|
|
||||||
import jakarta.servlet.http.HttpServletRequest;
|
|
||||||
import jakarta.servlet.http.HttpServletResponse;
|
|
||||||
|
|
||||||
import lombok.extern.slf4j.Slf4j;
|
|
||||||
|
|
||||||
import stirling.software.common.util.RegexPatternUtils;
|
|
||||||
import stirling.software.common.util.RequestUriUtils;
|
|
||||||
import stirling.software.proprietary.config.AuditConfigurationProperties;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Shared utilities for audit aspects to ensure consistent behavior across different audit
|
|
||||||
* mechanisms.
|
|
||||||
*/
|
|
||||||
@Slf4j
|
|
||||||
public class AuditUtils {
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Create a standard audit data map with common attributes based on the current audit level
|
|
||||||
*
|
|
||||||
* @param joinPoint The AspectJ join point
|
|
||||||
* @param auditLevel The current audit level
|
|
||||||
* @return A map with standard audit data
|
|
||||||
*/
|
|
||||||
public static Map<String, Object> createBaseAuditData(
|
|
||||||
ProceedingJoinPoint joinPoint, AuditLevel auditLevel) {
|
|
||||||
Map<String, Object> data = new HashMap<>();
|
|
||||||
|
|
||||||
// Common data for all levels
|
|
||||||
data.put("timestamp", Instant.now().toString());
|
|
||||||
|
|
||||||
// Add principal if available
|
|
||||||
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
|
|
||||||
if (auth != null && auth.getName() != null) {
|
|
||||||
data.put("principal", auth.getName());
|
|
||||||
} else {
|
|
||||||
data.put("principal", "system");
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add class name and method name only at VERBOSE level
|
|
||||||
if (auditLevel.includes(AuditLevel.VERBOSE)) {
|
|
||||||
data.put("className", joinPoint.getTarget().getClass().getName());
|
|
||||||
data.put(
|
|
||||||
"methodName",
|
|
||||||
((MethodSignature) joinPoint.getSignature()).getMethod().getName());
|
|
||||||
}
|
|
||||||
|
|
||||||
return data;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Add HTTP-specific information to the audit data if available
|
|
||||||
*
|
|
||||||
* @param data The existing audit data map
|
|
||||||
* @param httpMethod The HTTP method (GET, POST, etc.)
|
|
||||||
* @param path The request path
|
|
||||||
* @param auditLevel The current audit level
|
|
||||||
*/
|
|
||||||
public static void addHttpData(
|
|
||||||
Map<String, Object> data, String httpMethod, String path, AuditLevel auditLevel) {
|
|
||||||
if (httpMethod == null || path == null) {
|
|
||||||
return; // Skip if we don't have basic HTTP info
|
|
||||||
}
|
|
||||||
|
|
||||||
// BASIC level HTTP data
|
|
||||||
data.put("httpMethod", httpMethod);
|
|
||||||
data.put("path", path);
|
|
||||||
|
|
||||||
// Get request attributes safely
|
|
||||||
ServletRequestAttributes attrs =
|
|
||||||
(ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
|
|
||||||
if (attrs == null) {
|
|
||||||
return; // No request context available
|
|
||||||
}
|
|
||||||
|
|
||||||
HttpServletRequest req = attrs.getRequest();
|
|
||||||
if (req == null) {
|
|
||||||
return; // No request available
|
|
||||||
}
|
|
||||||
|
|
||||||
// STANDARD level HTTP data
|
|
||||||
if (auditLevel.includes(AuditLevel.STANDARD)) {
|
|
||||||
data.put("clientIp", req.getRemoteAddr());
|
|
||||||
data.put(
|
|
||||||
"sessionId",
|
|
||||||
req.getSession(false) != null ? req.getSession(false).getId() : null);
|
|
||||||
data.put("requestId", MDC.get("requestId"));
|
|
||||||
|
|
||||||
// Form data for POST/PUT/PATCH
|
|
||||||
if (("POST".equalsIgnoreCase(httpMethod)
|
|
||||||
|| "PUT".equalsIgnoreCase(httpMethod)
|
|
||||||
|| "PATCH".equalsIgnoreCase(httpMethod))
|
|
||||||
&& req.getContentType() != null) {
|
|
||||||
|
|
||||||
String contentType = req.getContentType();
|
|
||||||
if (contentType.contains(MediaType.APPLICATION_FORM_URLENCODED_VALUE)
|
|
||||||
|| contentType.contains(MediaType.MULTIPART_FORM_DATA_VALUE)) {
|
|
||||||
|
|
||||||
Map<String, String[]> params = new HashMap<>(req.getParameterMap());
|
|
||||||
// Remove CSRF token from logged parameters
|
|
||||||
params.remove("_csrf");
|
|
||||||
|
|
||||||
if (!params.isEmpty()) {
|
|
||||||
data.put("formParams", params);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Add file information to the audit data if available
|
|
||||||
*
|
|
||||||
* @param data The existing audit data map
|
|
||||||
* @param joinPoint The AspectJ join point
|
|
||||||
* @param auditLevel The current audit level
|
|
||||||
*/
|
|
||||||
public static void addFileData(
|
|
||||||
Map<String, Object> data, ProceedingJoinPoint joinPoint, AuditLevel auditLevel) {
|
|
||||||
if (auditLevel.includes(AuditLevel.STANDARD)) {
|
|
||||||
List<MultipartFile> files =
|
|
||||||
Arrays.stream(joinPoint.getArgs())
|
|
||||||
.filter(a -> a instanceof MultipartFile)
|
|
||||||
.map(a -> (MultipartFile) a)
|
|
||||||
.collect(Collectors.toList());
|
|
||||||
|
|
||||||
if (!files.isEmpty()) {
|
|
||||||
List<Map<String, Object>> fileInfos =
|
|
||||||
files.stream()
|
|
||||||
.map(
|
|
||||||
f -> {
|
|
||||||
Map<String, Object> m = new HashMap<>();
|
|
||||||
m.put("name", f.getOriginalFilename());
|
|
||||||
m.put("size", f.getSize());
|
|
||||||
m.put("type", f.getContentType());
|
|
||||||
return m;
|
|
||||||
})
|
|
||||||
.collect(Collectors.toList());
|
|
||||||
|
|
||||||
data.put("files", fileInfos);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Add method arguments to the audit data
|
|
||||||
*
|
|
||||||
* @param data The existing audit data map
|
|
||||||
* @param joinPoint The AspectJ join point
|
|
||||||
* @param auditLevel The current audit level
|
|
||||||
*/
|
|
||||||
public static void addMethodArguments(
|
|
||||||
Map<String, Object> data, ProceedingJoinPoint joinPoint, AuditLevel auditLevel) {
|
|
||||||
if (auditLevel.includes(AuditLevel.VERBOSE)) {
|
|
||||||
MethodSignature sig = (MethodSignature) joinPoint.getSignature();
|
|
||||||
String[] names = sig.getParameterNames();
|
|
||||||
Object[] vals = joinPoint.getArgs();
|
|
||||||
if (names != null && vals != null) {
|
|
||||||
IntStream.range(0, names.length)
|
|
||||||
.forEach(
|
|
||||||
i -> {
|
|
||||||
if (vals[i] != null) {
|
|
||||||
// Convert objects to safe string representation
|
|
||||||
data.put("arg_" + names[i], safeToString(vals[i], 500));
|
|
||||||
} else {
|
|
||||||
data.put("arg_" + names[i], null);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Safely convert an object to string with size limiting
|
|
||||||
*
|
|
||||||
* @param obj The object to convert
|
|
||||||
* @param maxLength Maximum length of the resulting string
|
|
||||||
* @return A safe string representation, truncated if needed
|
|
||||||
*/
|
|
||||||
public static String safeToString(Object obj, int maxLength) {
|
|
||||||
if (obj == null) {
|
|
||||||
return "null";
|
|
||||||
}
|
|
||||||
|
|
||||||
String result;
|
|
||||||
try {
|
|
||||||
// Handle common types directly to avoid toString() overhead
|
|
||||||
if (obj instanceof String) {
|
|
||||||
result = (String) obj;
|
|
||||||
} else if (obj instanceof Number || obj instanceof Boolean) {
|
|
||||||
result = obj.toString();
|
|
||||||
} else if (obj instanceof byte[]) {
|
|
||||||
result = "[binary data length=" + ((byte[]) obj).length + "]";
|
|
||||||
} else {
|
|
||||||
// For complex objects, use toString but handle exceptions
|
|
||||||
result = obj.toString();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Truncate if necessary
|
|
||||||
if (result != null && result.length() > maxLength) {
|
|
||||||
return StringUtils.truncate(result, maxLength - 3) + "...";
|
|
||||||
}
|
|
||||||
|
|
||||||
return result;
|
|
||||||
} catch (Exception e) {
|
|
||||||
// If toString() fails, return the class name
|
|
||||||
return "[" + obj.getClass().getName() + " - toString() failed]";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Determine if a method should be audited based on config and annotation
|
|
||||||
*
|
|
||||||
* @param method The method to check
|
|
||||||
* @param auditConfig The audit configuration
|
|
||||||
* @return true if the method should be audited
|
|
||||||
*/
|
|
||||||
public static boolean shouldAudit(Method method, AuditConfigurationProperties auditConfig) {
|
|
||||||
// First check if audit is globally enabled - fast path
|
|
||||||
if (!auditConfig.isEnabled()) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check for annotation override
|
|
||||||
Audited auditedAnnotation = method.getAnnotation(Audited.class);
|
|
||||||
AuditLevel requiredLevel =
|
|
||||||
(auditedAnnotation != null) ? auditedAnnotation.level() : AuditLevel.BASIC;
|
|
||||||
|
|
||||||
// Check if the required level is enabled
|
|
||||||
return auditConfig.getAuditLevel().includes(requiredLevel);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Add timing and response status data to the audit record
|
|
||||||
*
|
|
||||||
* @param data The audit data to add to
|
|
||||||
* @param startTime The start time in milliseconds
|
|
||||||
* @param response The HTTP response (may be null for non-HTTP methods)
|
|
||||||
* @param level The current audit level
|
|
||||||
* @param isHttpRequest Whether this is an HTTP request (controller) or a regular method call
|
|
||||||
*/
|
|
||||||
public static void addTimingData(
|
|
||||||
Map<String, Object> data,
|
|
||||||
long startTime,
|
|
||||||
HttpServletResponse response,
|
|
||||||
AuditLevel level,
|
|
||||||
boolean isHttpRequest) {
|
|
||||||
if (level.includes(AuditLevel.STANDARD)) {
|
|
||||||
// For HTTP requests, let ControllerAuditAspect handle timing separately
|
|
||||||
// For non-HTTP methods, add execution time here
|
|
||||||
if (!isHttpRequest) {
|
|
||||||
data.put("latencyMs", System.currentTimeMillis() - startTime);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add HTTP status code if available
|
|
||||||
if (response != null) {
|
|
||||||
try {
|
|
||||||
data.put("statusCode", response.getStatus());
|
|
||||||
} catch (Exception e) {
|
|
||||||
// Ignore - response might be in an inconsistent state
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Resolve the event type to use for auditing, considering annotations and context
|
|
||||||
*
|
|
||||||
* @param method The method being audited
|
|
||||||
* @param controller The controller class
|
|
||||||
* @param path The request path (may be null for non-HTTP methods)
|
|
||||||
* @param httpMethod The HTTP method (may be null for non-HTTP methods)
|
|
||||||
* @param annotation The @Audited annotation (may be null)
|
|
||||||
* @return The resolved event type (never null)
|
|
||||||
*/
|
|
||||||
public static AuditEventType resolveEventType(
|
|
||||||
Method method,
|
|
||||||
Class<?> controller,
|
|
||||||
String path,
|
|
||||||
String httpMethod,
|
|
||||||
Audited annotation) {
|
|
||||||
// First check if we have an explicit annotation
|
|
||||||
if (annotation != null && annotation.type() != AuditEventType.HTTP_REQUEST) {
|
|
||||||
return annotation.type();
|
|
||||||
}
|
|
||||||
|
|
||||||
// For HTTP methods, infer based on controller and path
|
|
||||||
if (httpMethod != null && path != null) {
|
|
||||||
String cls = controller.getSimpleName().toLowerCase(Locale.ROOT);
|
|
||||||
String pkg = controller.getPackage().getName().toLowerCase(Locale.ROOT);
|
|
||||||
|
|
||||||
if ("GET".equals(httpMethod)) return AuditEventType.HTTP_REQUEST;
|
|
||||||
|
|
||||||
if (cls.contains("user")
|
|
||||||
|| cls.contains("auth")
|
|
||||||
|| pkg.contains("auth")
|
|
||||||
|| path.startsWith("/user")
|
|
||||||
|| path.startsWith("/login")) {
|
|
||||||
return AuditEventType.USER_PROFILE_UPDATE;
|
|
||||||
} else if (cls.contains("admin")
|
|
||||||
|| path.startsWith("/admin")
|
|
||||||
|| path.startsWith("/settings")) {
|
|
||||||
return AuditEventType.SETTINGS_CHANGED;
|
|
||||||
} else if (cls.contains("file")
|
|
||||||
|| path.startsWith("/file")
|
|
||||||
|| RegexPatternUtils.getInstance()
|
|
||||||
.getUploadDownloadPathPattern()
|
|
||||||
.matcher(path)
|
|
||||||
.matches()) {
|
|
||||||
return AuditEventType.FILE_OPERATION;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Default for non-HTTP methods or when no specific match
|
|
||||||
return AuditEventType.PDF_PROCESS;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Determine the appropriate audit level to use
|
|
||||||
*
|
|
||||||
* @param method The method to check
|
|
||||||
* @param defaultLevel The default level to use if no annotation present
|
|
||||||
* @param auditConfig The audit configuration
|
|
||||||
* @return The audit level to use
|
|
||||||
*/
|
|
||||||
public static AuditLevel getEffectiveAuditLevel(
|
|
||||||
Method method, AuditLevel defaultLevel, AuditConfigurationProperties auditConfig) {
|
|
||||||
Audited auditedAnnotation = method.getAnnotation(Audited.class);
|
|
||||||
if (auditedAnnotation != null) {
|
|
||||||
// Method has @Audited - use its level
|
|
||||||
return auditedAnnotation.level();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Use default level (typically from global config)
|
|
||||||
return defaultLevel;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Determine the appropriate audit event type to use
|
|
||||||
*
|
|
||||||
* @param method The method being audited
|
|
||||||
* @param controller The controller class
|
|
||||||
* @param path The request path
|
|
||||||
* @param httpMethod The HTTP method
|
|
||||||
* @return The determined audit event type
|
|
||||||
*/
|
|
||||||
public static AuditEventType determineAuditEventType(
|
|
||||||
Method method, Class<?> controller, String path, String httpMethod) {
|
|
||||||
// First check for explicit annotation
|
|
||||||
Audited auditedAnnotation = method.getAnnotation(Audited.class);
|
|
||||||
if (auditedAnnotation != null) {
|
|
||||||
return auditedAnnotation.type();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Otherwise infer from controller and path
|
|
||||||
String cls = controller.getSimpleName().toLowerCase(Locale.ROOT);
|
|
||||||
String pkg = controller.getPackage().getName().toLowerCase(Locale.ROOT);
|
|
||||||
|
|
||||||
if ("GET".equals(httpMethod)) return AuditEventType.HTTP_REQUEST;
|
|
||||||
|
|
||||||
if (cls.contains("user")
|
|
||||||
|| cls.contains("auth")
|
|
||||||
|| pkg.contains("auth")
|
|
||||||
|| path.startsWith("/user")
|
|
||||||
|| path.startsWith("/login")) {
|
|
||||||
return AuditEventType.USER_PROFILE_UPDATE;
|
|
||||||
} else if (cls.contains("admin")
|
|
||||||
|| path.startsWith("/admin")
|
|
||||||
|| path.startsWith("/settings")) {
|
|
||||||
return AuditEventType.SETTINGS_CHANGED;
|
|
||||||
} else if (cls.contains("file")
|
|
||||||
|| path.startsWith("/file")
|
|
||||||
|| RegexPatternUtils.getInstance()
|
|
||||||
.getUploadDownloadPathPattern()
|
|
||||||
.matcher(path)
|
|
||||||
.matches()) {
|
|
||||||
return AuditEventType.FILE_OPERATION;
|
|
||||||
} else {
|
|
||||||
return AuditEventType.PDF_PROCESS;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get the current HTTP request if available
|
|
||||||
*
|
|
||||||
* @return The current request or null if not in a request context
|
|
||||||
*/
|
|
||||||
public static HttpServletRequest getCurrentRequest() {
|
|
||||||
ServletRequestAttributes attrs =
|
|
||||||
(ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
|
|
||||||
return attrs != null ? attrs.getRequest() : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Check if a GET request is for a static resource
|
|
||||||
*
|
|
||||||
* @param request The HTTP request
|
|
||||||
* @return true if this is a static resource request
|
|
||||||
*/
|
|
||||||
public static boolean isStaticResourceRequest(HttpServletRequest request) {
|
|
||||||
return request != null
|
|
||||||
&& !RequestUriUtils.isTrackableResource(
|
|
||||||
request.getContextPath(), request.getRequestURI());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+142
-59
@@ -9,6 +9,7 @@ import org.aspectj.lang.ProceedingJoinPoint;
|
|||||||
import org.aspectj.lang.annotation.Around;
|
import org.aspectj.lang.annotation.Around;
|
||||||
import org.aspectj.lang.annotation.Aspect;
|
import org.aspectj.lang.annotation.Aspect;
|
||||||
import org.aspectj.lang.reflect.MethodSignature;
|
import org.aspectj.lang.reflect.MethodSignature;
|
||||||
|
import org.slf4j.MDC;
|
||||||
import org.springframework.stereotype.Component;
|
import org.springframework.stereotype.Component;
|
||||||
import org.springframework.web.bind.annotation.DeleteMapping;
|
import org.springframework.web.bind.annotation.DeleteMapping;
|
||||||
import org.springframework.web.bind.annotation.GetMapping;
|
import org.springframework.web.bind.annotation.GetMapping;
|
||||||
@@ -37,7 +38,7 @@ import stirling.software.proprietary.service.AuditService;
|
|||||||
@Slf4j
|
@Slf4j
|
||||||
@RequiredArgsConstructor
|
@RequiredArgsConstructor
|
||||||
@org.springframework.core.annotation.Order(
|
@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 {
|
public class ControllerAuditAspect {
|
||||||
|
|
||||||
private final AuditService auditService;
|
private final AuditService auditService;
|
||||||
@@ -92,7 +93,7 @@ public class ControllerAuditAspect {
|
|||||||
|
|
||||||
// Fast path: check if auditing is enabled before doing any work
|
// Fast path: check if auditing is enabled before doing any work
|
||||||
// This avoids all data collection if auditing is disabled
|
// This avoids all data collection if auditing is disabled
|
||||||
if (!AuditUtils.shouldAudit(method, auditConfig)) {
|
if (!auditService.shouldAudit(method, auditConfig)) {
|
||||||
return joinPoint.proceed();
|
return joinPoint.proceed();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -110,8 +111,14 @@ public class ControllerAuditAspect {
|
|||||||
|
|
||||||
// Skip static GET resources
|
// Skip static GET resources
|
||||||
if ("GET".equals(httpMethod)) {
|
if ("GET".equals(httpMethod)) {
|
||||||
HttpServletRequest maybe = AuditUtils.getCurrentRequest();
|
HttpServletRequest maybe = auditService.getCurrentRequest();
|
||||||
if (maybe != null && AuditUtils.isStaticResourceRequest(maybe)) {
|
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();
|
return joinPoint.proceed();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -121,75 +128,143 @@ public class ControllerAuditAspect {
|
|||||||
HttpServletRequest req = attrs != null ? attrs.getRequest() : null;
|
HttpServletRequest req = attrs != null ? attrs.getRequest() : null;
|
||||||
HttpServletResponse resp = attrs != null ? attrs.getResponse() : 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
|
// EARLY CAPTURE: Capture from SecurityContext on request thread, store in MDC for async
|
||||||
Map<String, Object> data = AuditUtils.createBaseAuditData(joinPoint, level);
|
// propagation
|
||||||
|
// MDC.put is necessary for background threads to inherit audit context
|
||||||
// Add HTTP-specific information
|
String capturedPrincipal = previousPrincipal;
|
||||||
AuditUtils.addHttpData(data, httpMethod, path, level);
|
if (capturedPrincipal == null) {
|
||||||
|
capturedPrincipal = auditService.captureCurrentPrincipal();
|
||||||
// Add file information if present
|
MDC.put("auditPrincipal", capturedPrincipal);
|
||||||
AuditUtils.addFileData(data, joinPoint, level);
|
|
||||||
|
|
||||||
// Add method arguments if at VERBOSE level
|
|
||||||
if (level.includes(AuditLevel.VERBOSE)) {
|
|
||||||
AuditUtils.addMethodArguments(data, joinPoint, level);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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 {
|
try {
|
||||||
result = joinPoint.proceed();
|
// Avoid duplicate events for controller methods explicitly annotated with @Audited.
|
||||||
data.put("outcome", "success");
|
// @Audited methods are audited by AuditAspect.
|
||||||
} 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)
|
|
||||||
if (auditedAnnotation != null) {
|
if (auditedAnnotation != null) {
|
||||||
String typeString = auditedAnnotation.typeString();
|
return joinPoint.proceed();
|
||||||
if (eventType == AuditEventType.HTTP_REQUEST
|
}
|
||||||
&& StringUtils.isNotEmpty(typeString)) {
|
|
||||||
auditService.audit(typeString, data, level);
|
long start = System.currentTimeMillis();
|
||||||
return result;
|
|
||||||
|
// Use auditService to create the base audit data
|
||||||
|
Map<String, Object> data = auditService.createBaseAuditData(joinPoint, level);
|
||||||
|
|
||||||
|
// Add HTTP-specific information
|
||||||
|
auditService.addHttpData(data, httpMethod, path, level);
|
||||||
|
|
||||||
|
// Add file information if present
|
||||||
|
auditService.addFileData(data, joinPoint, level);
|
||||||
|
|
||||||
|
// Add method arguments if at VERBOSE level
|
||||||
|
if (level.includes(AuditLevel.VERBOSE)) {
|
||||||
|
auditService.addMethodArguments(data, joinPoint, level);
|
||||||
|
}
|
||||||
|
|
||||||
|
Object result = null;
|
||||||
|
try {
|
||||||
|
result = joinPoint.proceed();
|
||||||
|
data.put("outcome", "success");
|
||||||
|
} catch (Throwable ex) {
|
||||||
|
data.put("outcome", "failure");
|
||||||
|
data.put("errorType", ex.getClass().getSimpleName());
|
||||||
|
data.put("errorMessage", ex.getMessage());
|
||||||
|
throw ex;
|
||||||
|
} finally {
|
||||||
|
// Handle timing directly for HTTP requests
|
||||||
|
if (level.includes(AuditLevel.STANDARD)) {
|
||||||
|
data.put("latencyMs", System.currentTimeMillis() - start);
|
||||||
|
if (resp != null) data.put("statusCode", resp.getStatus());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Call auditService but with isHttpRequest=true to skip additional timing
|
||||||
|
auditService.addTimingData(data, start, resp, level, true);
|
||||||
|
|
||||||
|
// Resolve the event type using the unified method
|
||||||
|
AuditEventType eventType =
|
||||||
|
auditService.resolveEventType(
|
||||||
|
method,
|
||||||
|
joinPoint.getTarget().getClass(),
|
||||||
|
path,
|
||||||
|
httpMethod,
|
||||||
|
auditedAnnotation);
|
||||||
|
|
||||||
|
// Add result only if operation result capture is explicitly enabled
|
||||||
|
// Skip result for UI_DATA events to avoid storing large response bodies
|
||||||
|
if (auditService.shouldCaptureOperationResults()
|
||||||
|
&& result != null
|
||||||
|
&& eventType != AuditEventType.UI_DATA) {
|
||||||
|
// Use safe string conversion with size limiting
|
||||||
|
data.put("result", auditService.safeToString(result, 1000));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if we should use string type instead (for backward compatibility)
|
||||||
|
if (auditedAnnotation != null) {
|
||||||
|
String typeString = auditedAnnotation.typeString();
|
||||||
|
if (eventType == AuditEventType.HTTP_REQUEST
|
||||||
|
&& StringUtils.isNotEmpty(typeString)) {
|
||||||
|
auditService.audit(
|
||||||
|
capturedPrincipal,
|
||||||
|
capturedOrigin,
|
||||||
|
capturedIp,
|
||||||
|
typeString,
|
||||||
|
data,
|
||||||
|
level);
|
||||||
|
} else {
|
||||||
|
// Use the enum type with early-captured values
|
||||||
|
auditService.audit(
|
||||||
|
capturedPrincipal,
|
||||||
|
capturedOrigin,
|
||||||
|
capturedIp,
|
||||||
|
eventType,
|
||||||
|
data,
|
||||||
|
level);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Use the enum type with early-captured values
|
||||||
|
auditService.audit(
|
||||||
|
capturedPrincipal, capturedOrigin, capturedIp, eventType, data, level);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Use the enum type
|
return result;
|
||||||
auditService.audit(eventType, data, level);
|
} finally {
|
||||||
|
restoreMdcValue("auditPrincipal", previousPrincipal);
|
||||||
|
restoreMdcValue("auditOrigin", previousOrigin);
|
||||||
|
restoreMdcValue("auditIp", previousIp);
|
||||||
}
|
}
|
||||||
return result;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Using AuditUtils.determineAuditEventType instead
|
// Using AuditUtils.determineAuditEventType instead
|
||||||
|
|
||||||
private String getRequestPath(Method method, String httpMethod) {
|
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 = "";
|
String base = "";
|
||||||
RequestMapping cm = method.getDeclaringClass().getAnnotation(RequestMapping.class);
|
RequestMapping cm = method.getDeclaringClass().getAnnotation(RequestMapping.class);
|
||||||
if (cm != null && cm.value().length > 0) base = cm.value()[0];
|
if (cm != null && cm.value().length > 0) base = cm.value()[0];
|
||||||
@@ -211,5 +286,13 @@ public class ControllerAuditAspect {
|
|||||||
return base + mp;
|
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
|
// Using AuditUtils.getCurrentRequest instead
|
||||||
}
|
}
|
||||||
|
|||||||
+13
-2
@@ -23,6 +23,9 @@ public class AuditConfigurationProperties {
|
|||||||
private final boolean enabled;
|
private final boolean enabled;
|
||||||
private final int level;
|
private final int level;
|
||||||
private final int retentionDays;
|
private final int retentionDays;
|
||||||
|
private final boolean captureFileHash;
|
||||||
|
private final boolean capturePdfAuthor;
|
||||||
|
private final boolean captureOperationResults;
|
||||||
|
|
||||||
public AuditConfigurationProperties(ApplicationProperties applicationProperties) {
|
public AuditConfigurationProperties(ApplicationProperties applicationProperties) {
|
||||||
ApplicationProperties.Premium.EnterpriseFeatures.Audit auditConfig =
|
ApplicationProperties.Premium.EnterpriseFeatures.Audit auditConfig =
|
||||||
@@ -37,11 +40,19 @@ public class AuditConfigurationProperties {
|
|||||||
// Retention days (0 means infinite)
|
// Retention days (0 means infinite)
|
||||||
this.retentionDays = auditConfig.getRetentionDays();
|
this.retentionDays = auditConfig.getRetentionDays();
|
||||||
|
|
||||||
|
// Metadata and detail capture flags
|
||||||
|
this.captureFileHash = auditConfig.isCaptureFileHash();
|
||||||
|
this.capturePdfAuthor = auditConfig.isCapturePdfAuthor();
|
||||||
|
this.captureOperationResults = auditConfig.isCaptureOperationResults();
|
||||||
|
|
||||||
log.debug(
|
log.debug(
|
||||||
"Initialized audit configuration: enabled={}, level={}, retentionDays={} (0=infinite)",
|
"Initialized audit configuration: enabled={}, level={}, retentionDays={} (0=infinite), fileHash={}, pdfAuthor={}, operationResults={}",
|
||||||
this.enabled,
|
this.enabled,
|
||||||
this.level,
|
this.level,
|
||||||
this.retentionDays);
|
this.retentionDays,
|
||||||
|
this.captureFileHash,
|
||||||
|
this.capturePdfAuthor,
|
||||||
|
this.captureOperationResults);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
+548
-65
@@ -1,5 +1,6 @@
|
|||||||
package stirling.software.proprietary.controller.api;
|
package stirling.software.proprietary.controller.api;
|
||||||
|
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
import java.time.Instant;
|
import java.time.Instant;
|
||||||
import java.time.LocalDate;
|
import java.time.LocalDate;
|
||||||
import java.time.LocalDateTime;
|
import java.time.LocalDateTime;
|
||||||
@@ -18,6 +19,7 @@ import org.springframework.http.MediaType;
|
|||||||
import org.springframework.http.ResponseEntity;
|
import org.springframework.http.ResponseEntity;
|
||||||
import org.springframework.security.access.prepost.PreAuthorize;
|
import org.springframework.security.access.prepost.PreAuthorize;
|
||||||
import org.springframework.web.bind.annotation.GetMapping;
|
import org.springframework.web.bind.annotation.GetMapping;
|
||||||
|
import org.springframework.web.bind.annotation.PostMapping;
|
||||||
import org.springframework.web.bind.annotation.RequestParam;
|
import org.springframework.web.bind.annotation.RequestParam;
|
||||||
|
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
@@ -44,12 +46,13 @@ public class AuditRestController {
|
|||||||
private final ObjectMapper objectMapper;
|
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 page Page number (0-indexed)
|
||||||
* @param pageSize Number of items per page
|
* @param pageSize Number of items per page
|
||||||
* @param eventType Filter by event type
|
* @param eventType Filter by event type(s) - can be single value or array
|
||||||
* @param username Filter by username (principal)
|
* @param username Filter by username(s) - can be single value or array
|
||||||
* @param startDate Filter start date
|
* @param startDate Filter start date
|
||||||
* @param endDate Filter end date
|
* @param endDate Filter end date
|
||||||
* @return Paginated audit events response
|
* @return Paginated audit events response
|
||||||
@@ -58,8 +61,8 @@ public class AuditRestController {
|
|||||||
public ResponseEntity<AuditEventsResponse> getAuditEvents(
|
public ResponseEntity<AuditEventsResponse> getAuditEvents(
|
||||||
@RequestParam(value = "page", defaultValue = "0") int page,
|
@RequestParam(value = "page", defaultValue = "0") int page,
|
||||||
@RequestParam(value = "pageSize", defaultValue = "30") int pageSize,
|
@RequestParam(value = "pageSize", defaultValue = "30") int pageSize,
|
||||||
@RequestParam(value = "eventType", required = false) String eventType,
|
@RequestParam(value = "eventType", required = false) String[] eventTypes,
|
||||||
@RequestParam(value = "username", required = false) String username,
|
@RequestParam(value = "username", required = false) String[] usernames,
|
||||||
@RequestParam(value = "startDate", required = false)
|
@RequestParam(value = "startDate", required = false)
|
||||||
@DateTimeFormat(iso = DateTimeFormat.ISO.DATE)
|
@DateTimeFormat(iso = DateTimeFormat.ISO.DATE)
|
||||||
LocalDate startDate,
|
LocalDate startDate,
|
||||||
@@ -70,33 +73,45 @@ public class AuditRestController {
|
|||||||
Pageable pageable = PageRequest.of(page, pageSize, Sort.by("timestamp").descending());
|
Pageable pageable = PageRequest.of(page, pageSize, Sort.by("timestamp").descending());
|
||||||
Page<PersistentAuditEvent> events;
|
Page<PersistentAuditEvent> events;
|
||||||
|
|
||||||
|
// Convert arrays to lists
|
||||||
|
List<String> eventTypeList =
|
||||||
|
(eventTypes != null && eventTypes.length > 0) ? Arrays.asList(eventTypes) : null;
|
||||||
|
List<String> usernameList =
|
||||||
|
(usernames != null && usernames.length > 0) ? Arrays.asList(usernames) : null;
|
||||||
|
|
||||||
|
Instant startInstant = null;
|
||||||
|
Instant endInstant = null;
|
||||||
|
if (startDate != null && endDate != null) {
|
||||||
|
startInstant = startDate.atStartOfDay(ZoneId.systemDefault()).toInstant();
|
||||||
|
endInstant = endDate.plusDays(1).atStartOfDay(ZoneId.systemDefault()).toInstant();
|
||||||
|
}
|
||||||
|
|
||||||
// Apply filters based on provided parameters
|
// Apply filters based on provided parameters
|
||||||
if (eventType != null && username != null && startDate != null && endDate != null) {
|
if (eventTypeList != null
|
||||||
Instant start = startDate.atStartOfDay(ZoneId.systemDefault()).toInstant();
|
&& usernameList != null
|
||||||
Instant end = endDate.plusDays(1).atStartOfDay(ZoneId.systemDefault()).toInstant();
|
&& startInstant != null
|
||||||
|
&& endInstant != null) {
|
||||||
events =
|
events =
|
||||||
auditRepository.findByPrincipalAndTypeAndTimestampBetween(
|
auditRepository.findByTypeInAndPrincipalInAndTimestampBetween(
|
||||||
username, eventType, start, end, pageable);
|
eventTypeList, usernameList, startInstant, endInstant, pageable);
|
||||||
} else if (eventType != null && username != null) {
|
} else if (eventTypeList != null && usernameList != 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();
|
|
||||||
events =
|
events =
|
||||||
auditRepository.findByPrincipalAndTimestampBetween(
|
auditRepository.findByTypeInAndPrincipalIn(
|
||||||
username, start, end, pageable);
|
eventTypeList, usernameList, pageable);
|
||||||
} else if (startDate != null && endDate != null) {
|
} else if (eventTypeList != null && startInstant != null && endInstant != null) {
|
||||||
Instant start = startDate.atStartOfDay(ZoneId.systemDefault()).toInstant();
|
events =
|
||||||
Instant end = endDate.plusDays(1).atStartOfDay(ZoneId.systemDefault()).toInstant();
|
auditRepository.findByTypeInAndTimestampBetween(
|
||||||
events = auditRepository.findByTimestampBetween(start, end, pageable);
|
eventTypeList, startInstant, endInstant, pageable);
|
||||||
} else if (eventType != null) {
|
} else if (usernameList != null && startInstant != null && endInstant != null) {
|
||||||
events = auditRepository.findByType(eventType, pageable);
|
events =
|
||||||
} else if (username != null) {
|
auditRepository.findByPrincipalInAndTimestampBetween(
|
||||||
events = auditRepository.findByPrincipal(username, pageable);
|
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 {
|
} else {
|
||||||
events = auditRepository.findAll(pageable);
|
events = auditRepository.findAll(pageable);
|
||||||
}
|
}
|
||||||
@@ -258,11 +273,253 @@ public class AuditRestController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Export audit data in CSV or JSON format. Maps to frontend's exportData() call.
|
* Get audit statistics for KPI dashboard. Includes success rates, latency metrics, and top
|
||||||
|
* items.
|
||||||
|
*
|
||||||
|
* @param period Time period for statistics (day/week/month)
|
||||||
|
* @return Audit statistics data for dashboard KPI cards and enhanced charts
|
||||||
|
*/
|
||||||
|
@GetMapping("/audit-stats")
|
||||||
|
public ResponseEntity<AuditStatsData> getAuditStats(
|
||||||
|
@RequestParam(value = "period", defaultValue = "week") String period) {
|
||||||
|
|
||||||
|
// Calculate days based on period
|
||||||
|
int days;
|
||||||
|
switch (period.toLowerCase()) {
|
||||||
|
case "day":
|
||||||
|
days = 1;
|
||||||
|
break;
|
||||||
|
case "month":
|
||||||
|
days = 30;
|
||||||
|
break;
|
||||||
|
case "week":
|
||||||
|
default:
|
||||||
|
days = 7;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get events from the specified period and previous period
|
||||||
|
Instant now = Instant.now();
|
||||||
|
Instant start = now.minus(java.time.Duration.ofDays(days));
|
||||||
|
Instant prevStart = start.minus(java.time.Duration.ofDays(days));
|
||||||
|
|
||||||
|
List<PersistentAuditEvent> currentEvents = auditRepository.findByTimestampAfter(start);
|
||||||
|
List<PersistentAuditEvent> prevEvents =
|
||||||
|
auditRepository.findAllByTimestampBetweenForExport(prevStart, start);
|
||||||
|
|
||||||
|
// Compute metrics for current period
|
||||||
|
AuditMetrics currentMetrics = computeMetrics(currentEvents);
|
||||||
|
AuditMetrics prevMetrics = computeMetrics(prevEvents);
|
||||||
|
|
||||||
|
// Get hourly distribution using DB aggregation
|
||||||
|
List<Object[]> hourlyData = auditRepository.histogramByHourBetween(start, now);
|
||||||
|
Map<String, Long> hourlyDistribution = new TreeMap<>();
|
||||||
|
for (int h = 0; h < 24; h++) {
|
||||||
|
hourlyDistribution.put(String.format("%02d", h), 0L);
|
||||||
|
}
|
||||||
|
for (Object[] row : hourlyData) {
|
||||||
|
int hour = ((Number) row[0]).intValue();
|
||||||
|
long count = ((Number) row[1]).longValue();
|
||||||
|
hourlyDistribution.put(String.format("%02d", hour), count);
|
||||||
|
}
|
||||||
|
|
||||||
|
return ResponseEntity.ok(
|
||||||
|
AuditStatsData.builder()
|
||||||
|
.totalEvents(currentMetrics.totalEvents)
|
||||||
|
.prevTotalEvents(prevMetrics.totalEvents)
|
||||||
|
.uniqueUsers(currentMetrics.uniqueUsers)
|
||||||
|
.prevUniqueUsers(prevMetrics.uniqueUsers)
|
||||||
|
.successRate(currentMetrics.successRate)
|
||||||
|
.prevSuccessRate(prevMetrics.successRate)
|
||||||
|
.avgLatencyMs(currentMetrics.avgLatencyMs)
|
||||||
|
.prevAvgLatencyMs(prevMetrics.avgLatencyMs)
|
||||||
|
.errorCount(currentMetrics.errorCount)
|
||||||
|
.topEventType(currentMetrics.topEventType)
|
||||||
|
.topUser(currentMetrics.topUser)
|
||||||
|
.eventsByType(currentMetrics.eventsByType)
|
||||||
|
.eventsByUser(currentMetrics.eventsByUser)
|
||||||
|
.topTools(currentMetrics.topTools)
|
||||||
|
.hourlyDistribution(hourlyDistribution)
|
||||||
|
.build());
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Compute metrics from a list of audit events. */
|
||||||
|
private AuditMetrics computeMetrics(List<PersistentAuditEvent> events) {
|
||||||
|
if (events.isEmpty()) {
|
||||||
|
return AuditMetrics.builder().build();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Count by type
|
||||||
|
Map<String, Long> eventsByType =
|
||||||
|
events.stream()
|
||||||
|
.collect(
|
||||||
|
Collectors.groupingBy(
|
||||||
|
PersistentAuditEvent::getType, Collectors.counting()));
|
||||||
|
|
||||||
|
// Count by principal (user)
|
||||||
|
Map<String, Long> eventsByUser =
|
||||||
|
events.stream()
|
||||||
|
.collect(
|
||||||
|
Collectors.groupingBy(
|
||||||
|
PersistentAuditEvent::getPrincipal, Collectors.counting()));
|
||||||
|
|
||||||
|
// Parse JSON data once for success rate, latency, tool extraction, and error counting
|
||||||
|
long successCount = 0;
|
||||||
|
long failureCount = 0;
|
||||||
|
long errorCount = 0;
|
||||||
|
long totalLatencyMs = 0;
|
||||||
|
long latencyCount = 0;
|
||||||
|
Map<String, Long> topTools = new HashMap<>();
|
||||||
|
|
||||||
|
for (PersistentAuditEvent event : events) {
|
||||||
|
if (event.getData() != null) {
|
||||||
|
try {
|
||||||
|
@SuppressWarnings("unchecked")
|
||||||
|
Map<String, Object> data = objectMapper.readValue(event.getData(), Map.class);
|
||||||
|
|
||||||
|
// Track success/failure (safe type conversion)
|
||||||
|
// Check both "status" (current) and "outcome" (legacy) for compatibility
|
||||||
|
Object statusObj = data.get("status");
|
||||||
|
if (statusObj == null) {
|
||||||
|
statusObj = data.get("outcome");
|
||||||
|
}
|
||||||
|
String status = null;
|
||||||
|
if (statusObj instanceof String) {
|
||||||
|
status = (String) statusObj;
|
||||||
|
} else if (statusObj != null) {
|
||||||
|
status = String.valueOf(statusObj);
|
||||||
|
}
|
||||||
|
if ("success".equals(status)) {
|
||||||
|
successCount++;
|
||||||
|
} else if ("failure".equals(status)) {
|
||||||
|
failureCount++;
|
||||||
|
errorCount++;
|
||||||
|
} else {
|
||||||
|
// Check statusCode for error counting (when status is not explicit failure)
|
||||||
|
Object statusCode = data.get("statusCode");
|
||||||
|
if (statusCode != null) {
|
||||||
|
try {
|
||||||
|
int statusCodeVal;
|
||||||
|
if (statusCode instanceof Number) {
|
||||||
|
statusCodeVal = ((Number) statusCode).intValue();
|
||||||
|
} else if (statusCode instanceof String) {
|
||||||
|
statusCodeVal = Integer.parseInt((String) statusCode);
|
||||||
|
} else {
|
||||||
|
statusCodeVal = 0;
|
||||||
|
}
|
||||||
|
if (statusCodeVal >= 400) {
|
||||||
|
errorCount++;
|
||||||
|
}
|
||||||
|
} catch (NumberFormatException e) {
|
||||||
|
log.trace("Failed to parse statusCode value: {}", statusCode);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Track latency (safe conversion to handle strings/numbers)
|
||||||
|
Object latency = data.get("latencyMs");
|
||||||
|
if (latency != null) {
|
||||||
|
try {
|
||||||
|
long latencyVal;
|
||||||
|
if (latency instanceof Number) {
|
||||||
|
latencyVal = ((Number) latency).longValue();
|
||||||
|
} else if (latency instanceof String) {
|
||||||
|
latencyVal = Long.parseLong((String) latency);
|
||||||
|
} else {
|
||||||
|
latencyVal = 0;
|
||||||
|
}
|
||||||
|
totalLatencyMs += latencyVal;
|
||||||
|
latencyCount++;
|
||||||
|
} catch (NumberFormatException e) {
|
||||||
|
log.trace("Failed to parse latency value: {}", latency);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract tool from path (safe type conversion)
|
||||||
|
Object pathObj = data.get("path");
|
||||||
|
String path = null;
|
||||||
|
if (pathObj instanceof String) {
|
||||||
|
path = (String) pathObj;
|
||||||
|
} else if (pathObj != null) {
|
||||||
|
path = String.valueOf(pathObj);
|
||||||
|
}
|
||||||
|
if (path != null && !path.isEmpty()) {
|
||||||
|
String[] parts = path.split("/");
|
||||||
|
if (parts.length > 0) {
|
||||||
|
String tool = parts[parts.length - 1];
|
||||||
|
if (!tool.isEmpty()) {
|
||||||
|
topTools.put(tool, topTools.getOrDefault(tool, 0L) + 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (JacksonException e) {
|
||||||
|
log.trace("Failed to parse audit event data: {}", event.getData());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate success rate
|
||||||
|
double successRate = 0;
|
||||||
|
long totalWithOutcome = successCount + failureCount;
|
||||||
|
if (totalWithOutcome > 0) {
|
||||||
|
successRate = (successCount * 100.0) / totalWithOutcome;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate average latency
|
||||||
|
double avgLatencyMs = 0;
|
||||||
|
if (latencyCount > 0) {
|
||||||
|
avgLatencyMs = totalLatencyMs / (double) latencyCount;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get top event type
|
||||||
|
String topEventType =
|
||||||
|
eventsByType.entrySet().stream()
|
||||||
|
.max((e1, e2) -> Long.compare(e1.getValue(), e2.getValue()))
|
||||||
|
.map(Map.Entry::getKey)
|
||||||
|
.orElse("");
|
||||||
|
|
||||||
|
// Get top user
|
||||||
|
String topUser =
|
||||||
|
eventsByUser.entrySet().stream()
|
||||||
|
.max((e1, e2) -> Long.compare(e1.getValue(), e2.getValue()))
|
||||||
|
.map(Map.Entry::getKey)
|
||||||
|
.orElse("");
|
||||||
|
|
||||||
|
// Sort and limit top tools to 10
|
||||||
|
Map<String, Long> topToolsSorted =
|
||||||
|
topTools.entrySet().stream()
|
||||||
|
.sorted((e1, e2) -> Long.compare(e2.getValue(), e1.getValue()))
|
||||||
|
.limit(10)
|
||||||
|
.collect(
|
||||||
|
Collectors.toMap(
|
||||||
|
Map.Entry::getKey,
|
||||||
|
Map.Entry::getValue,
|
||||||
|
(e1, e2) -> e1,
|
||||||
|
LinkedHashMap::new));
|
||||||
|
|
||||||
|
return AuditMetrics.builder()
|
||||||
|
.totalEvents(events.size())
|
||||||
|
.uniqueUsers((int) eventsByUser.size())
|
||||||
|
.successRate(successRate)
|
||||||
|
.avgLatencyMs(avgLatencyMs)
|
||||||
|
.errorCount(errorCount)
|
||||||
|
.topEventType(topEventType)
|
||||||
|
.topUser(topUser)
|
||||||
|
.eventsByType(eventsByType)
|
||||||
|
.eventsByUser(eventsByUser)
|
||||||
|
.topTools(topToolsSorted)
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Export audit data in CSV or JSON format. Maps to frontend's exportData() call. Supports both
|
||||||
|
* single values and multi-select arrays for eventType and username.
|
||||||
*
|
*
|
||||||
* @param format Export format (csv or json)
|
* @param format Export format (csv or json)
|
||||||
* @param eventType Filter by event type
|
* @param fields Comma-separated list of fields to include (e.g.,
|
||||||
* @param username Filter by username
|
* "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 startDate Filter start date
|
||||||
* @param endDate Filter end date
|
* @param endDate Filter end date
|
||||||
* @return File download response
|
* @return File download response
|
||||||
@@ -270,8 +527,9 @@ public class AuditRestController {
|
|||||||
@GetMapping("/audit-export")
|
@GetMapping("/audit-export")
|
||||||
public ResponseEntity<byte[]> exportAuditData(
|
public ResponseEntity<byte[]> exportAuditData(
|
||||||
@RequestParam(value = "format", defaultValue = "csv") String format,
|
@RequestParam(value = "format", defaultValue = "csv") String format,
|
||||||
@RequestParam(value = "eventType", required = false) String eventType,
|
@RequestParam(value = "fields", required = false) String fields,
|
||||||
@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)
|
@RequestParam(value = "startDate", required = false)
|
||||||
@DateTimeFormat(iso = DateTimeFormat.ISO.DATE)
|
@DateTimeFormat(iso = DateTimeFormat.ISO.DATE)
|
||||||
LocalDate startDate,
|
LocalDate startDate,
|
||||||
@@ -282,34 +540,44 @@ public class AuditRestController {
|
|||||||
// Get data with same filtering as getAuditEvents
|
// Get data with same filtering as getAuditEvents
|
||||||
List<PersistentAuditEvent> events;
|
List<PersistentAuditEvent> events;
|
||||||
|
|
||||||
if (eventType != null && username != null && startDate != null && endDate != null) {
|
// Convert arrays to lists
|
||||||
Instant start = startDate.atStartOfDay(ZoneId.systemDefault()).toInstant();
|
List<String> eventTypeList =
|
||||||
Instant end = endDate.plusDays(1).atStartOfDay(ZoneId.systemDefault()).toInstant();
|
(eventTypes != null && eventTypes.length > 0) ? Arrays.asList(eventTypes) : null;
|
||||||
|
List<String> usernameList =
|
||||||
|
(usernames != null && usernames.length > 0) ? Arrays.asList(usernames) : null;
|
||||||
|
|
||||||
|
Instant startInstant = null;
|
||||||
|
Instant endInstant = null;
|
||||||
|
if (startDate != null && endDate != null) {
|
||||||
|
startInstant = startDate.atStartOfDay(ZoneId.systemDefault()).toInstant();
|
||||||
|
endInstant = endDate.plusDays(1).atStartOfDay(ZoneId.systemDefault()).toInstant();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (eventTypeList != null
|
||||||
|
&& usernameList != null
|
||||||
|
&& startInstant != null
|
||||||
|
&& endInstant != null) {
|
||||||
events =
|
events =
|
||||||
auditRepository.findAllByPrincipalAndTypeAndTimestampBetweenForExport(
|
auditRepository.findByTypeInAndPrincipalInAndTimestampBetweenForExport(
|
||||||
username, eventType, start, end);
|
eventTypeList, usernameList, startInstant, endInstant);
|
||||||
} else if (eventType != null && username != null) {
|
} else if (eventTypeList != null && usernameList != 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();
|
|
||||||
events =
|
events =
|
||||||
auditRepository.findAllByTypeAndTimestampBetweenForExport(
|
auditRepository.findByTypeInAndPrincipalInForExport(
|
||||||
eventType, start, end);
|
eventTypeList, usernameList);
|
||||||
} else if (username != null && startDate != null && endDate != null) {
|
} else if (eventTypeList != null && startInstant != null && endInstant != null) {
|
||||||
Instant start = startDate.atStartOfDay(ZoneId.systemDefault()).toInstant();
|
|
||||||
Instant end = endDate.plusDays(1).atStartOfDay(ZoneId.systemDefault()).toInstant();
|
|
||||||
events =
|
events =
|
||||||
auditRepository.findAllByPrincipalAndTimestampBetweenForExport(
|
auditRepository.findByTypeInAndTimestampBetweenForExport(
|
||||||
username, start, end);
|
eventTypeList, startInstant, endInstant);
|
||||||
} else if (startDate != null && endDate != null) {
|
} else if (usernameList != null && startInstant != null && endInstant != null) {
|
||||||
Instant start = startDate.atStartOfDay(ZoneId.systemDefault()).toInstant();
|
events =
|
||||||
Instant end = endDate.plusDays(1).atStartOfDay(ZoneId.systemDefault()).toInstant();
|
auditRepository.findByPrincipalInAndTimestampBetweenForExport(
|
||||||
events = auditRepository.findAllByTimestampBetweenForExport(start, end);
|
usernameList, startInstant, endInstant);
|
||||||
} else if (eventType != null) {
|
} else if (startInstant != null && endInstant != null) {
|
||||||
events = auditRepository.findByTypeForExport(eventType);
|
events = auditRepository.findAllByTimestampBetweenForExport(startInstant, endInstant);
|
||||||
} else if (username != null) {
|
} else if (eventTypeList != null) {
|
||||||
events = auditRepository.findAllByPrincipalForExport(username);
|
events = auditRepository.findByTypeInForExport(eventTypeList);
|
||||||
|
} else if (usernameList != null) {
|
||||||
|
events = auditRepository.findByPrincipalInForExport(usernameList);
|
||||||
} else {
|
} else {
|
||||||
events = auditRepository.findAll();
|
events = auditRepository.findAll();
|
||||||
}
|
}
|
||||||
@@ -318,7 +586,7 @@ public class AuditRestController {
|
|||||||
if ("json".equalsIgnoreCase(format)) {
|
if ("json".equalsIgnoreCase(format)) {
|
||||||
return exportAsJson(events);
|
return exportAsJson(events);
|
||||||
} else {
|
} 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()
|
return AuditEventDto.builder()
|
||||||
.id(String.valueOf(event.getId()))
|
.id(String.valueOf(event.getId()))
|
||||||
.timestamp(event.getTimestamp().toString())
|
.timestamp(event.getTimestamp().toString())
|
||||||
.eventType(event.getType())
|
.eventType(event.getType())
|
||||||
.username(event.getPrincipal())
|
.username(event.getPrincipal())
|
||||||
.ipAddress((String) details.getOrDefault("ipAddress", "")) // Extract if available
|
.ipAddress(ipAddress)
|
||||||
.details(details)
|
.details(details)
|
||||||
.build();
|
.build();
|
||||||
}
|
}
|
||||||
|
|
||||||
private ResponseEntity<byte[]> exportAsCsv(List<PersistentAuditEvent> events) {
|
private ResponseEntity<byte[]> exportAsCsv(List<PersistentAuditEvent> events, String fields) {
|
||||||
|
// Parse selected fields (comma-separated:
|
||||||
|
// date,username,tool,documentName,author,fileHash,ipAddress,etc)
|
||||||
|
Set<String> selectedFields = new HashSet<>();
|
||||||
|
if (fields != null && !fields.trim().isEmpty()) {
|
||||||
|
String[] fieldArray = fields.split(",");
|
||||||
|
for (String field : fieldArray) {
|
||||||
|
selectedFields.add(field.trim().toLowerCase());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// If no fields specified, use default technical export
|
||||||
|
if (selectedFields.isEmpty()) {
|
||||||
|
return exportAsDefaultCsv(events);
|
||||||
|
}
|
||||||
|
|
||||||
|
StringBuilder csv = new StringBuilder();
|
||||||
|
|
||||||
|
// Build header based on selected fields
|
||||||
|
List<String> headerOrder = new ArrayList<>();
|
||||||
|
if (selectedFields.contains("date")) headerOrder.add("date");
|
||||||
|
if (selectedFields.contains("username")) headerOrder.add("username");
|
||||||
|
if (selectedFields.contains("ipaddress")) headerOrder.add("ipaddress");
|
||||||
|
if (selectedFields.contains("tool")) headerOrder.add("tool");
|
||||||
|
if (selectedFields.contains("documentname")) headerOrder.add("documentname");
|
||||||
|
if (selectedFields.contains("outcome")) headerOrder.add("outcome");
|
||||||
|
if (selectedFields.contains("author")) headerOrder.add("author");
|
||||||
|
if (selectedFields.contains("filehash")) headerOrder.add("filehash");
|
||||||
|
if (selectedFields.contains("operationresults")) headerOrder.add("operationresults");
|
||||||
|
if (selectedFields.contains("eventtype")) headerOrder.add("eventtype");
|
||||||
|
|
||||||
|
// Write header
|
||||||
|
for (int i = 0; i < headerOrder.size(); i++) {
|
||||||
|
csv.append(capitalizeHeader(headerOrder.get(i)));
|
||||||
|
if (i < headerOrder.size() - 1) csv.append(",");
|
||||||
|
}
|
||||||
|
csv.append("\n");
|
||||||
|
|
||||||
|
DateTimeFormatter formatter = DateTimeFormatter.ISO_INSTANT;
|
||||||
|
|
||||||
|
// Write data rows
|
||||||
|
for (PersistentAuditEvent event : events) {
|
||||||
|
Map<String, String> rowData = extractEventData(event, formatter);
|
||||||
|
|
||||||
|
for (int i = 0; i < headerOrder.size(); i++) {
|
||||||
|
csv.append(escapeCSV(rowData.getOrDefault(headerOrder.get(i), "")));
|
||||||
|
if (i < headerOrder.size() - 1) csv.append(",");
|
||||||
|
}
|
||||||
|
csv.append("\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
byte[] csvBytes = csv.toString().getBytes(StandardCharsets.UTF_8);
|
||||||
|
HttpHeaders headers = new HttpHeaders();
|
||||||
|
headers.setContentType(MediaType.parseMediaType("text/csv;charset=UTF-8"));
|
||||||
|
headers.setContentDispositionFormData(
|
||||||
|
"attachment", "audit_export_" + System.currentTimeMillis() + ".csv");
|
||||||
|
|
||||||
|
return ResponseEntity.ok().headers(headers).body(csvBytes);
|
||||||
|
}
|
||||||
|
|
||||||
|
private ResponseEntity<byte[]> exportAsDefaultCsv(List<PersistentAuditEvent> events) {
|
||||||
StringBuilder csv = new StringBuilder();
|
StringBuilder csv = new StringBuilder();
|
||||||
csv.append("ID,Principal,Type,Timestamp,Data\n");
|
csv.append("ID,Principal,Type,Timestamp,Data\n");
|
||||||
|
|
||||||
@@ -362,15 +702,102 @@ public class AuditRestController {
|
|||||||
csv.append(escapeCSV(event.getData())).append("\n");
|
csv.append(escapeCSV(event.getData())).append("\n");
|
||||||
}
|
}
|
||||||
|
|
||||||
byte[] csvBytes = csv.toString().getBytes();
|
byte[] csvBytes = csv.toString().getBytes(StandardCharsets.UTF_8);
|
||||||
|
|
||||||
HttpHeaders headers = new HttpHeaders();
|
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");
|
headers.setContentDispositionFormData("attachment", "audit_export.csv");
|
||||||
|
|
||||||
return ResponseEntity.ok().headers(headers).body(csvBytes);
|
return ResponseEntity.ok().headers(headers).body(csvBytes);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private Map<String, String> extractEventData(
|
||||||
|
PersistentAuditEvent event, DateTimeFormatter formatter) {
|
||||||
|
Map<String, String> data = new HashMap<>();
|
||||||
|
|
||||||
|
data.put("date", formatter.format(event.getTimestamp()));
|
||||||
|
data.put("username", event.getPrincipal());
|
||||||
|
data.put("eventtype", event.getType());
|
||||||
|
data.put("ipaddress", "");
|
||||||
|
data.put("tool", "");
|
||||||
|
data.put("documentname", "");
|
||||||
|
data.put("outcome", "");
|
||||||
|
data.put("author", "");
|
||||||
|
data.put("filehash", "");
|
||||||
|
data.put("operationresults", "");
|
||||||
|
|
||||||
|
if (event.getData() != null) {
|
||||||
|
try {
|
||||||
|
@SuppressWarnings("unchecked")
|
||||||
|
Map<String, Object> eventData = objectMapper.readValue(event.getData(), Map.class);
|
||||||
|
|
||||||
|
// Extract IP address (check both clientIp and __ipAddress)
|
||||||
|
String ipAddress = "";
|
||||||
|
if (eventData.containsKey("clientIp")) {
|
||||||
|
ipAddress = String.valueOf(eventData.getOrDefault("clientIp", ""));
|
||||||
|
} else if (eventData.containsKey("__ipAddress")) {
|
||||||
|
ipAddress = String.valueOf(eventData.getOrDefault("__ipAddress", ""));
|
||||||
|
}
|
||||||
|
if (!ipAddress.isEmpty()) {
|
||||||
|
data.put("ipaddress", ipAddress);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract outcome (success/failure), supporting legacy "status" key
|
||||||
|
if (eventData.containsKey("outcome")) {
|
||||||
|
data.put("outcome", String.valueOf(eventData.getOrDefault("outcome", "")));
|
||||||
|
} else if (eventData.containsKey("status")) {
|
||||||
|
data.put("outcome", String.valueOf(eventData.getOrDefault("status", "")));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract operation result if present
|
||||||
|
if (eventData.containsKey("result")) {
|
||||||
|
data.put(
|
||||||
|
"operationresults",
|
||||||
|
String.valueOf(eventData.getOrDefault("result", "")));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract tool from path
|
||||||
|
if (eventData.containsKey("path")) {
|
||||||
|
String path = (String) eventData.get("path");
|
||||||
|
if (path != null) {
|
||||||
|
String[] parts = path.split("/");
|
||||||
|
data.put("tool", parts.length > 0 ? parts[parts.length - 1] : "");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract file information
|
||||||
|
@SuppressWarnings("unchecked")
|
||||||
|
List<Map<String, Object>> files =
|
||||||
|
(List<Map<String, Object>>) eventData.get("files");
|
||||||
|
if (files != null && !files.isEmpty()) {
|
||||||
|
Map<String, Object> firstFile = files.get(0);
|
||||||
|
data.put("documentname", String.valueOf(firstFile.getOrDefault("name", "")));
|
||||||
|
data.put("author", String.valueOf(firstFile.getOrDefault("pdfAuthor", "")));
|
||||||
|
data.put("filehash", String.valueOf(firstFile.getOrDefault("fileHash", "")));
|
||||||
|
}
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.trace("Failed to parse audit event data: {}", event.getData());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
private String capitalizeHeader(String field) {
|
||||||
|
return switch (field.toLowerCase()) {
|
||||||
|
case "date" -> "Date";
|
||||||
|
case "username" -> "Username";
|
||||||
|
case "ipaddress" -> "IP Address";
|
||||||
|
case "tool" -> "Tool";
|
||||||
|
case "documentname" -> "Document Name";
|
||||||
|
case "outcome" -> "Outcome";
|
||||||
|
case "author" -> "Author";
|
||||||
|
case "filehash" -> "File Hash";
|
||||||
|
case "operationresults" -> "Operation Results";
|
||||||
|
case "eventtype" -> "Event Type";
|
||||||
|
default -> field;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
private ResponseEntity<byte[]> exportAsJson(List<PersistentAuditEvent> events) {
|
private ResponseEntity<byte[]> exportAsJson(List<PersistentAuditEvent> events) {
|
||||||
try {
|
try {
|
||||||
byte[] jsonBytes = objectMapper.writeValueAsBytes(events);
|
byte[] jsonBytes = objectMapper.writeValueAsBytes(events);
|
||||||
@@ -431,4 +858,60 @@ public class AuditRestController {
|
|||||||
private List<String> labels;
|
private List<String> labels;
|
||||||
private List<Integer> values;
|
private List<Integer> values;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@lombok.Data
|
||||||
|
@lombok.Builder
|
||||||
|
public static class AuditStatsData {
|
||||||
|
private long totalEvents;
|
||||||
|
private long prevTotalEvents;
|
||||||
|
private int uniqueUsers;
|
||||||
|
private int prevUniqueUsers;
|
||||||
|
private double successRate;
|
||||||
|
private double prevSuccessRate;
|
||||||
|
private double avgLatencyMs;
|
||||||
|
private double prevAvgLatencyMs;
|
||||||
|
private long errorCount;
|
||||||
|
private String topEventType;
|
||||||
|
private String topUser;
|
||||||
|
private Map<String, Long> eventsByType;
|
||||||
|
private Map<String, Long> eventsByUser;
|
||||||
|
private Map<String, Long> topTools;
|
||||||
|
private Map<String, Long> hourlyDistribution;
|
||||||
|
}
|
||||||
|
|
||||||
|
@lombok.Data
|
||||||
|
@lombok.Builder
|
||||||
|
public static class AuditMetrics {
|
||||||
|
private long totalEvents;
|
||||||
|
private int uniqueUsers;
|
||||||
|
private double successRate;
|
||||||
|
private double avgLatencyMs;
|
||||||
|
private long errorCount;
|
||||||
|
private String topEventType;
|
||||||
|
private String topUser;
|
||||||
|
private Map<String, Long> eventsByType;
|
||||||
|
private Map<String, Long> eventsByUser;
|
||||||
|
private Map<String, Long> topTools;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clear all audit data from the database. This is an irreversible operation. Requires ADMIN
|
||||||
|
* role.
|
||||||
|
*
|
||||||
|
* @return Success response
|
||||||
|
*/
|
||||||
|
@PostMapping("/audit-clear-all")
|
||||||
|
public ResponseEntity<?> clearAllAuditData() {
|
||||||
|
try {
|
||||||
|
// Delete all audit events
|
||||||
|
auditRepository.deleteAll();
|
||||||
|
log.warn("All audit data has been cleared by admin user");
|
||||||
|
return ResponseEntity.ok()
|
||||||
|
.body(Map.of("message", "All audit data has been cleared successfully"));
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.error("Error clearing audit data", e);
|
||||||
|
return ResponseEntity.internalServerError()
|
||||||
|
.body("Failed to clear audit data: " + e.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+11
@@ -127,6 +127,13 @@ public class ProprietaryUIDataController {
|
|||||||
data.setRetentionDays(auditConfig.getRetentionDays());
|
data.setRetentionDays(auditConfig.getRetentionDays());
|
||||||
data.setAuditLevels(AuditLevel.values());
|
data.setAuditLevels(AuditLevel.values());
|
||||||
data.setAuditEventTypes(AuditEventType.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);
|
return ResponseEntity.ok(data);
|
||||||
}
|
}
|
||||||
@@ -560,6 +567,10 @@ public class ProprietaryUIDataController {
|
|||||||
private int retentionDays;
|
private int retentionDays;
|
||||||
private AuditLevel[] auditLevels;
|
private AuditLevel[] auditLevels;
|
||||||
private AuditEventType[] auditEventTypes;
|
private AuditEventType[] auditEventTypes;
|
||||||
|
private boolean pdfMetadataEnabled;
|
||||||
|
private boolean captureFileHash;
|
||||||
|
private boolean capturePdfAuthor;
|
||||||
|
private boolean captureOperationResults;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Data
|
@Data
|
||||||
|
|||||||
+30
-54
@@ -1,5 +1,7 @@
|
|||||||
package stirling.software.proprietary.controller.api;
|
package stirling.software.proprietary.controller.api;
|
||||||
|
|
||||||
|
import java.time.Duration;
|
||||||
|
import java.time.Instant;
|
||||||
import java.util.*;
|
import java.util.*;
|
||||||
import java.util.stream.Collectors;
|
import java.util.stream.Collectors;
|
||||||
|
|
||||||
@@ -32,34 +34,32 @@ public class UsageRestController {
|
|||||||
private final ObjectMapper objectMapper;
|
private final ObjectMapper objectMapper;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get endpoint statistics derived from audit events. This endpoint analyzes HTTP_REQUEST audit
|
* Get endpoint statistics derived from audit events. This endpoint analyzes audit events
|
||||||
* events to generate usage statistics.
|
* filtered by type to generate usage statistics.
|
||||||
*
|
*
|
||||||
* @param limit Optional limit on number of endpoints to return
|
* @param limit Optional limit on number of endpoints to return
|
||||||
* @param dataType Type of data to include: "all" (default), "api" (API endpoints excluding
|
* @param dataType Type of data to include: "all" (default), "api" (operational endpoints), or
|
||||||
* auth), or "ui" (non-API endpoints)
|
* "ui" (UI data endpoints)
|
||||||
|
* @param days Lookback window in days (default 30, clamped to 1-365)
|
||||||
* @return Endpoint statistics response
|
* @return Endpoint statistics response
|
||||||
*/
|
*/
|
||||||
@GetMapping("/usage-endpoint-statistics")
|
@GetMapping("/usage-endpoint-statistics")
|
||||||
public ResponseEntity<EndpointStatisticsResponse> getEndpointStatistics(
|
public ResponseEntity<EndpointStatisticsResponse> getEndpointStatistics(
|
||||||
@RequestParam(value = "limit", required = false) Integer limit,
|
@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
|
int lookbackDays = Math.max(1, Math.min(days, 365));
|
||||||
List<PersistentAuditEvent> httpEvents =
|
|
||||||
auditRepository.findByTypeForExport(AuditEventType.HTTP_REQUEST.name());
|
// Get audit events filtered by type
|
||||||
|
List<PersistentAuditEvent> events = getEventsByDataType(dataType, lookbackDays);
|
||||||
|
|
||||||
// Count visits per endpoint
|
// Count visits per endpoint
|
||||||
Map<String, Long> endpointCounts = new HashMap<>();
|
Map<String, Long> endpointCounts = new HashMap<>();
|
||||||
|
|
||||||
for (PersistentAuditEvent event : httpEvents) {
|
for (PersistentAuditEvent event : events) {
|
||||||
String endpoint = extractEndpointFromAuditData(event.getData());
|
String endpoint = extractEndpointFromAuditData(event.getData());
|
||||||
if (endpoint != null) {
|
if (endpoint != null) {
|
||||||
// Apply data type filter
|
|
||||||
if (!shouldIncludeEndpoint(endpoint, dataType)) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
endpointCounts.merge(endpoint, 1L, Long::sum);
|
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 "all", "api" (not UI_DATA), or "ui" (UI_DATA only)
|
||||||
* @param dataType The filter type: "all", "api", or "ui"
|
* @param days lookback window in days
|
||||||
* @return true if the endpoint should be included, false otherwise
|
* @return List of audit events matching the data type filter
|
||||||
*/
|
*/
|
||||||
private boolean shouldIncludeEndpoint(String endpoint, String dataType) {
|
private List<PersistentAuditEvent> getEventsByDataType(String dataType, int days) {
|
||||||
|
Instant start = Instant.now().minus(Duration.ofDays(days));
|
||||||
|
|
||||||
if ("all".equalsIgnoreCase(dataType)) {
|
if ("all".equalsIgnoreCase(dataType)) {
|
||||||
return true;
|
return auditRepository.findByTimestampAfter(start);
|
||||||
}
|
|
||||||
|
|
||||||
boolean isApiEndpoint = isApiEndpoint(endpoint);
|
|
||||||
|
|
||||||
if ("api".equalsIgnoreCase(dataType)) {
|
|
||||||
return isApiEndpoint;
|
|
||||||
} else if ("ui".equalsIgnoreCase(dataType)) {
|
} 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 new ArrayList<>();
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// DTOs for response formatting
|
// DTOs for response formatting
|
||||||
|
|||||||
+88
@@ -68,6 +68,10 @@ public interface PersistentAuditEventRepository extends JpaRepository<Persistent
|
|||||||
@Query("SELECT e FROM PersistentAuditEvent e WHERE e.type = :type")
|
@Query("SELECT e FROM PersistentAuditEvent e WHERE e.type = :type")
|
||||||
List<PersistentAuditEvent> findByTypeForExport(@Param("type") String type);
|
List<PersistentAuditEvent> findByTypeForExport(@Param("type") String type);
|
||||||
|
|
||||||
|
@Query("SELECT e FROM PersistentAuditEvent e WHERE e.type = :type AND e.timestamp > :startDate")
|
||||||
|
List<PersistentAuditEvent> findByTypeAndTimestampAfterForExport(
|
||||||
|
@Param("type") String type, @Param("startDate") Instant startDate);
|
||||||
|
|
||||||
@Query("SELECT e FROM PersistentAuditEvent e WHERE e.timestamp BETWEEN :startDate AND :endDate")
|
@Query("SELECT e FROM PersistentAuditEvent e WHERE e.timestamp BETWEEN :startDate AND :endDate")
|
||||||
List<PersistentAuditEvent> findAllByTimestampBetweenForExport(
|
List<PersistentAuditEvent> findAllByTimestampBetweenForExport(
|
||||||
@Param("startDate") Instant startDate, @Param("endDate") Instant endDate);
|
@Param("startDate") Instant startDate, @Param("endDate") Instant endDate);
|
||||||
@@ -171,4 +175,88 @@ public interface PersistentAuditEventRepository extends JpaRepository<Persistent
|
|||||||
Optional<PersistentAuditEvent> findTopByPrincipalOrderByTimestampDesc(String principal);
|
Optional<PersistentAuditEvent> findTopByPrincipalOrderByTimestampDesc(String principal);
|
||||||
|
|
||||||
Optional<PersistentAuditEvent> findTopByTypeOrderByTimestampDesc(String type);
|
Optional<PersistentAuditEvent> findTopByTypeOrderByTimestampDesc(String type);
|
||||||
|
|
||||||
|
// Multi-value queries for filtering by multiple types and/or principals
|
||||||
|
@Query("SELECT e FROM PersistentAuditEvent e WHERE e.type IN :types")
|
||||||
|
Page<PersistentAuditEvent> findByTypeIn(@Param("types") List<String> types, Pageable pageable);
|
||||||
|
|
||||||
|
@Query("SELECT e FROM PersistentAuditEvent e WHERE e.principal IN :principals")
|
||||||
|
Page<PersistentAuditEvent> findByPrincipalIn(
|
||||||
|
@Param("principals") List<String> principals, Pageable pageable);
|
||||||
|
|
||||||
|
@Query(
|
||||||
|
"SELECT e FROM PersistentAuditEvent e WHERE e.type IN :types AND e.timestamp BETWEEN :startDate AND :endDate")
|
||||||
|
Page<PersistentAuditEvent> findByTypeInAndTimestampBetween(
|
||||||
|
@Param("types") List<String> types,
|
||||||
|
@Param("startDate") Instant startDate,
|
||||||
|
@Param("endDate") Instant endDate,
|
||||||
|
Pageable pageable);
|
||||||
|
|
||||||
|
@Query(
|
||||||
|
"SELECT e FROM PersistentAuditEvent e WHERE e.principal IN :principals AND e.timestamp BETWEEN :startDate AND :endDate")
|
||||||
|
Page<PersistentAuditEvent> findByPrincipalInAndTimestampBetween(
|
||||||
|
@Param("principals") List<String> principals,
|
||||||
|
@Param("startDate") Instant startDate,
|
||||||
|
@Param("endDate") Instant endDate,
|
||||||
|
Pageable pageable);
|
||||||
|
|
||||||
|
@Query(
|
||||||
|
"SELECT e FROM PersistentAuditEvent e WHERE e.type IN :types AND e.principal IN :principals")
|
||||||
|
Page<PersistentAuditEvent> findByTypeInAndPrincipalIn(
|
||||||
|
@Param("types") List<String> types,
|
||||||
|
@Param("principals") List<String> principals,
|
||||||
|
Pageable pageable);
|
||||||
|
|
||||||
|
@Query(
|
||||||
|
"SELECT e FROM PersistentAuditEvent e WHERE e.type IN :types AND e.principal IN :principals AND e.timestamp BETWEEN :startDate AND :endDate")
|
||||||
|
Page<PersistentAuditEvent> findByTypeInAndPrincipalInAndTimestampBetween(
|
||||||
|
@Param("types") List<String> types,
|
||||||
|
@Param("principals") List<String> principals,
|
||||||
|
@Param("startDate") Instant startDate,
|
||||||
|
@Param("endDate") Instant endDate,
|
||||||
|
Pageable pageable);
|
||||||
|
|
||||||
|
// Export versions (non-paged)
|
||||||
|
@Query("SELECT e FROM PersistentAuditEvent e WHERE e.type IN :types")
|
||||||
|
List<PersistentAuditEvent> findByTypeInForExport(@Param("types") List<String> types);
|
||||||
|
|
||||||
|
@Query("SELECT e FROM PersistentAuditEvent e WHERE e.principal IN :principals")
|
||||||
|
List<PersistentAuditEvent> findByPrincipalInForExport(
|
||||||
|
@Param("principals") List<String> principals);
|
||||||
|
|
||||||
|
@Query(
|
||||||
|
"SELECT e FROM PersistentAuditEvent e WHERE e.type IN :types AND e.timestamp BETWEEN :startDate AND :endDate")
|
||||||
|
List<PersistentAuditEvent> findByTypeInAndTimestampBetweenForExport(
|
||||||
|
@Param("types") List<String> types,
|
||||||
|
@Param("startDate") Instant startDate,
|
||||||
|
@Param("endDate") Instant endDate);
|
||||||
|
|
||||||
|
@Query(
|
||||||
|
"SELECT e FROM PersistentAuditEvent e WHERE e.principal IN :principals AND e.timestamp BETWEEN :startDate AND :endDate")
|
||||||
|
List<PersistentAuditEvent> findByPrincipalInAndTimestampBetweenForExport(
|
||||||
|
@Param("principals") List<String> principals,
|
||||||
|
@Param("startDate") Instant startDate,
|
||||||
|
@Param("endDate") Instant endDate);
|
||||||
|
|
||||||
|
@Query(
|
||||||
|
"SELECT e FROM PersistentAuditEvent e WHERE e.type IN :types AND e.principal IN :principals")
|
||||||
|
List<PersistentAuditEvent> findByTypeInAndPrincipalInForExport(
|
||||||
|
@Param("types") List<String> types, @Param("principals") List<String> principals);
|
||||||
|
|
||||||
|
@Query(
|
||||||
|
"SELECT e FROM PersistentAuditEvent e WHERE e.type IN :types AND e.principal IN :principals AND e.timestamp BETWEEN :startDate AND :endDate")
|
||||||
|
List<PersistentAuditEvent> findByTypeInAndPrincipalInAndTimestampBetweenForExport(
|
||||||
|
@Param("types") List<String> types,
|
||||||
|
@Param("principals") List<String> principals,
|
||||||
|
@Param("startDate") Instant startDate,
|
||||||
|
@Param("endDate") Instant endDate);
|
||||||
|
|
||||||
|
// Query events excluding a specific type (used for analytics where we want to exclude UI_DATA)
|
||||||
|
@Query("SELECT e FROM PersistentAuditEvent e WHERE e.type != :excludeType")
|
||||||
|
List<PersistentAuditEvent> findAllExceptTypeForExport(@Param("excludeType") String excludeType);
|
||||||
|
|
||||||
|
@Query(
|
||||||
|
"SELECT e FROM PersistentAuditEvent e WHERE e.type != :excludeType AND e.timestamp > :startDate")
|
||||||
|
List<PersistentAuditEvent> findAllExceptTypeAndTimestampAfterForExport(
|
||||||
|
@Param("excludeType") String excludeType, @Param("startDate") Instant startDate);
|
||||||
}
|
}
|
||||||
|
|||||||
+28
-11
@@ -16,6 +16,7 @@ import java.util.Optional;
|
|||||||
import java.util.UUID;
|
import java.util.UUID;
|
||||||
import java.util.function.Supplier;
|
import java.util.function.Supplier;
|
||||||
|
|
||||||
|
import org.slf4j.MDC;
|
||||||
import org.springframework.context.MessageSource;
|
import org.springframework.context.MessageSource;
|
||||||
import org.springframework.context.i18n.LocaleContextHolder;
|
import org.springframework.context.i18n.LocaleContextHolder;
|
||||||
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
|
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
|
||||||
@@ -522,19 +523,35 @@ public class UserService implements UserServiceInterface {
|
|||||||
|
|
||||||
@Override
|
@Override
|
||||||
public String getCurrentUsername() {
|
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) {
|
if (principal instanceof UserDetails detailsUser) {
|
||||||
return detailsUser.getUsername();
|
return detailsUser.getUsername();
|
||||||
} else if (principal instanceof User domainUser) {
|
} else if (principal instanceof User domainUser) {
|
||||||
return domainUser.getUsername();
|
return domainUser.getUsername();
|
||||||
} else if (principal instanceof OAuth2User oAuth2User) {
|
} else if (principal instanceof OAuth2User oAuth2User) {
|
||||||
return oAuth2User.getAttribute(oAuth2.getUseAsUsername());
|
return oAuth2User.getAttribute(oAuth2.getUseAsUsername());
|
||||||
} else if (principal instanceof CustomSaml2AuthenticatedPrincipal saml2User) {
|
} else if (principal instanceof CustomSaml2AuthenticatedPrincipal saml2User) {
|
||||||
return saml2User.name();
|
return saml2User.name();
|
||||||
} else if (principal instanceof String stringUser) {
|
} else if (principal instanceof String stringUser) {
|
||||||
return 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;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+804
-5
@@ -1,23 +1,55 @@
|
|||||||
package stirling.software.proprietary.service;
|
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.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.beans.factory.annotation.Qualifier;
|
||||||
import org.springframework.boot.actuate.audit.AuditEvent;
|
import org.springframework.boot.actuate.audit.AuditEvent;
|
||||||
import org.springframework.boot.actuate.audit.AuditEventRepository;
|
import org.springframework.boot.actuate.audit.AuditEventRepository;
|
||||||
|
import org.springframework.http.MediaType;
|
||||||
import org.springframework.security.core.Authentication;
|
import org.springframework.security.core.Authentication;
|
||||||
import org.springframework.security.core.context.SecurityContextHolder;
|
import org.springframework.security.core.context.SecurityContextHolder;
|
||||||
import org.springframework.stereotype.Service;
|
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 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.AuditEventType;
|
||||||
import stirling.software.proprietary.audit.AuditLevel;
|
import stirling.software.proprietary.audit.AuditLevel;
|
||||||
|
import stirling.software.proprietary.audit.Audited;
|
||||||
import stirling.software.proprietary.config.AuditConfigurationProperties;
|
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
|
* Service for audit event creation, data collection, and persistence. Combines persistence logic
|
||||||
* audit functionality in any component.
|
* with data collection utilities for comprehensive audit trail management.
|
||||||
*/
|
*/
|
||||||
@Slf4j
|
@Slf4j
|
||||||
@Service
|
@Service
|
||||||
@@ -26,16 +58,24 @@ public class AuditService {
|
|||||||
private final AuditEventRepository repository;
|
private final AuditEventRepository repository;
|
||||||
private final AuditConfigurationProperties auditConfig;
|
private final AuditConfigurationProperties auditConfig;
|
||||||
private final boolean runningEE;
|
private final boolean runningEE;
|
||||||
|
private final CustomPDFDocumentFactory pdfDocumentFactory;
|
||||||
|
private final JwtServiceInterface jwtService;
|
||||||
|
|
||||||
public AuditService(
|
public AuditService(
|
||||||
AuditEventRepository repository,
|
AuditEventRepository repository,
|
||||||
AuditConfigurationProperties auditConfig,
|
AuditConfigurationProperties auditConfig,
|
||||||
@Qualifier("runningEE") boolean runningEE) {
|
@Qualifier("runningEE") boolean runningEE,
|
||||||
|
CustomPDFDocumentFactory pdfDocumentFactory,
|
||||||
|
JwtServiceInterface jwtService) {
|
||||||
this.repository = repository;
|
this.repository = repository;
|
||||||
this.auditConfig = auditConfig;
|
this.auditConfig = auditConfig;
|
||||||
this.runningEE = runningEE;
|
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
|
* Record an audit event for the current authenticated user with a specific audit level using
|
||||||
* the standardized AuditEventType enum
|
* the standardized AuditEventType enum
|
||||||
@@ -53,7 +93,12 @@ public class AuditService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
String principal = getCurrentUsername();
|
String principal = getCurrentUsername();
|
||||||
repository.add(new AuditEvent(principal, type.name(), data));
|
|
||||||
|
// Add origin to the data map (captured here before async execution)
|
||||||
|
Map<String, Object> enrichedData = new java.util.HashMap<>(data);
|
||||||
|
enrichedData.put("__origin", determineOrigin());
|
||||||
|
|
||||||
|
repository.add(new AuditEvent(principal, type.name(), enrichedData));
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -115,7 +160,12 @@ public class AuditService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
String principal = getCurrentUsername();
|
String principal = getCurrentUsername();
|
||||||
repository.add(new AuditEvent(principal, type, data));
|
|
||||||
|
// Add origin to the data map (captured here before async execution)
|
||||||
|
Map<String, Object> enrichedData = new java.util.HashMap<>(data);
|
||||||
|
enrichedData.put("__origin", determineOrigin());
|
||||||
|
|
||||||
|
repository.add(new AuditEvent(principal, type, enrichedData));
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -161,9 +211,758 @@ public class AuditService {
|
|||||||
audit(principal, type, data, AuditLevel.STANDARD);
|
audit(principal, type, data, AuditLevel.STANDARD);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Record an audit event with pre-captured principal, origin, and IP (for use by audit aspects).
|
||||||
|
*/
|
||||||
|
public void audit(
|
||||||
|
String principal,
|
||||||
|
String origin,
|
||||||
|
String ipAddress,
|
||||||
|
AuditEventType type,
|
||||||
|
Map<String, Object> data,
|
||||||
|
AuditLevel level) {
|
||||||
|
if (!auditConfig.isEnabled()
|
||||||
|
|| !auditConfig.getAuditLevel().includes(level)
|
||||||
|
|| !runningEE) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add origin and IP to the data map (already captured before async)
|
||||||
|
Map<String, Object> enrichedData = new java.util.HashMap<>(data);
|
||||||
|
enrichedData.put("__origin", origin);
|
||||||
|
if (ipAddress != null) {
|
||||||
|
enrichedData.put("__ipAddress", ipAddress);
|
||||||
|
}
|
||||||
|
|
||||||
|
repository.add(new AuditEvent(principal, type.name(), enrichedData));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Record an audit event with pre-captured principal, origin, and IP using string type (for
|
||||||
|
* backward compatibility).
|
||||||
|
*/
|
||||||
|
public void audit(
|
||||||
|
String principal,
|
||||||
|
String origin,
|
||||||
|
String ipAddress,
|
||||||
|
String type,
|
||||||
|
Map<String, Object> data,
|
||||||
|
AuditLevel level) {
|
||||||
|
if (!auditConfig.isEnabled()
|
||||||
|
|| !auditConfig.getAuditLevel().includes(level)
|
||||||
|
|| !runningEE) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add origin and IP to the data map (already captured before async)
|
||||||
|
Map<String, Object> enrichedData = new java.util.HashMap<>(data);
|
||||||
|
enrichedData.put("__origin", origin);
|
||||||
|
if (ipAddress != null) {
|
||||||
|
enrichedData.put("__ipAddress", ipAddress);
|
||||||
|
}
|
||||||
|
|
||||||
|
repository.add(new AuditEvent(principal, type, enrichedData));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========== DATA COLLECTION METHODS ==========
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a standard audit data map with common attributes based on the current audit level
|
||||||
|
*
|
||||||
|
* @param joinPoint The AspectJ join point
|
||||||
|
* @param auditLevel The current audit level
|
||||||
|
* @return A map with standard audit data
|
||||||
|
*/
|
||||||
|
public Map<String, Object> createBaseAuditData(
|
||||||
|
ProceedingJoinPoint joinPoint, AuditLevel auditLevel) {
|
||||||
|
Map<String, Object> data = new HashMap<>();
|
||||||
|
|
||||||
|
// Common data for all levels
|
||||||
|
data.put("timestamp", Instant.now().toString());
|
||||||
|
|
||||||
|
// Add principal: prefer MDC (captured on request thread) over SecurityContext
|
||||||
|
// This ensures consistency in async contexts where SecurityContext may not be available
|
||||||
|
String principal = MDC.get("auditPrincipal");
|
||||||
|
if (principal == null) {
|
||||||
|
// Fallback: capture from SecurityContext if running in request thread
|
||||||
|
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
|
||||||
|
principal = (auth != null && auth.getName() != null) ? auth.getName() : "system";
|
||||||
|
}
|
||||||
|
data.put("principal", principal);
|
||||||
|
|
||||||
|
// Add class name and method name only at VERBOSE level
|
||||||
|
if (auditLevel.includes(AuditLevel.VERBOSE)) {
|
||||||
|
data.put("className", joinPoint.getTarget().getClass().getName());
|
||||||
|
data.put(
|
||||||
|
"methodName",
|
||||||
|
((MethodSignature) joinPoint.getSignature()).getMethod().getName());
|
||||||
|
}
|
||||||
|
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add HTTP-specific information to the audit data if available
|
||||||
|
*
|
||||||
|
* @param data The existing audit data map
|
||||||
|
* @param httpMethod The HTTP method (GET, POST, etc.)
|
||||||
|
* @param path The request path
|
||||||
|
* @param auditLevel The current audit level
|
||||||
|
*/
|
||||||
|
public void addHttpData(
|
||||||
|
Map<String, Object> data, String httpMethod, String path, AuditLevel auditLevel) {
|
||||||
|
if (httpMethod == null || path == null) {
|
||||||
|
return; // Skip if we don't have basic HTTP info
|
||||||
|
}
|
||||||
|
|
||||||
|
// BASIC level HTTP data
|
||||||
|
data.put("httpMethod", httpMethod);
|
||||||
|
data.put("path", path);
|
||||||
|
|
||||||
|
// Get request attributes safely
|
||||||
|
ServletRequestAttributes attrs =
|
||||||
|
(ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
|
||||||
|
if (attrs == null) {
|
||||||
|
return; // No request context available
|
||||||
|
}
|
||||||
|
|
||||||
|
HttpServletRequest req = attrs.getRequest();
|
||||||
|
if (req == null) {
|
||||||
|
return; // No request available
|
||||||
|
}
|
||||||
|
|
||||||
|
// STANDARD level HTTP data
|
||||||
|
if (auditLevel.includes(AuditLevel.STANDARD)) {
|
||||||
|
// Use extracted client IP (supports X-Forwarded-For, X-Real-IP behind proxies)
|
||||||
|
data.put("clientIp", extractClientIp(req));
|
||||||
|
data.put(
|
||||||
|
"sessionId",
|
||||||
|
req.getSession(false) != null ? req.getSession(false).getId() : null);
|
||||||
|
data.put("requestId", MDC.get("requestId"));
|
||||||
|
|
||||||
|
// Form data for POST/PUT/PATCH
|
||||||
|
if (("POST".equalsIgnoreCase(httpMethod)
|
||||||
|
|| "PUT".equalsIgnoreCase(httpMethod)
|
||||||
|
|| "PATCH".equalsIgnoreCase(httpMethod))
|
||||||
|
&& req.getContentType() != null) {
|
||||||
|
|
||||||
|
String contentType = req.getContentType();
|
||||||
|
if (contentType.contains(MediaType.APPLICATION_FORM_URLENCODED_VALUE)
|
||||||
|
|| contentType.contains(MediaType.MULTIPART_FORM_DATA_VALUE)) {
|
||||||
|
|
||||||
|
Map<String, String[]> params = new HashMap<>(req.getParameterMap());
|
||||||
|
// Remove CSRF token from logged parameters
|
||||||
|
params.remove("_csrf");
|
||||||
|
|
||||||
|
if (!params.isEmpty()) {
|
||||||
|
data.put("formParams", params);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add file information to the audit data if available
|
||||||
|
*
|
||||||
|
* @param data The existing audit data map
|
||||||
|
* @param joinPoint The AspectJ join point
|
||||||
|
* @param auditLevel The current audit level
|
||||||
|
*/
|
||||||
|
public void addFileData(
|
||||||
|
Map<String, Object> data, ProceedingJoinPoint joinPoint, AuditLevel auditLevel) {
|
||||||
|
if (auditLevel.includes(AuditLevel.STANDARD)) {
|
||||||
|
List<MultipartFile> files = new ArrayList<>();
|
||||||
|
|
||||||
|
// Extract files from multiple sources:
|
||||||
|
for (Object arg : joinPoint.getArgs()) {
|
||||||
|
// 1. Direct MultipartFile arguments
|
||||||
|
if (arg instanceof MultipartFile) {
|
||||||
|
files.add((MultipartFile) arg);
|
||||||
|
}
|
||||||
|
// 2. MultipartFile[] arrays (ConvertToPdfRequest, HandleDataRequest, etc.)
|
||||||
|
else if (arg instanceof MultipartFile[]) {
|
||||||
|
files.addAll(Arrays.asList((MultipartFile[]) arg));
|
||||||
|
}
|
||||||
|
// 3. PDFFile-based objects (request wrappers like OptimizePdfRequest,
|
||||||
|
// MergePdfsRequest)
|
||||||
|
else if (arg instanceof PDFFile) {
|
||||||
|
MultipartFile fileInput = ((PDFFile) arg).getFileInput();
|
||||||
|
if (fileInput != null) {
|
||||||
|
files.add(fileInput);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!files.isEmpty()) {
|
||||||
|
List<Map<String, Object>> fileInfos =
|
||||||
|
files.stream()
|
||||||
|
.map(
|
||||||
|
f -> {
|
||||||
|
Map<String, Object> m = new HashMap<>();
|
||||||
|
m.put("name", f.getOriginalFilename());
|
||||||
|
m.put("size", f.getSize());
|
||||||
|
m.put("type", f.getContentType());
|
||||||
|
|
||||||
|
// Add file metadata if enabled (independent of audit
|
||||||
|
// level)
|
||||||
|
if (auditConfig.isCaptureFileHash()
|
||||||
|
|| auditConfig.isCapturePdfAuthor()) {
|
||||||
|
addFileMetadata(m, f);
|
||||||
|
}
|
||||||
|
|
||||||
|
return m;
|
||||||
|
})
|
||||||
|
.collect(Collectors.toList());
|
||||||
|
|
||||||
|
data.put("files", fileInfos);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extract SHA-256 hash and PDF author metadata for a file
|
||||||
|
*
|
||||||
|
* @param fileData The file data map to add metadata to
|
||||||
|
* @param file The MultipartFile to extract metadata from
|
||||||
|
*/
|
||||||
|
private void addFileMetadata(Map<String, Object> fileData, MultipartFile file) {
|
||||||
|
// Extract SHA-256 hash if enabled (using streaming to avoid loading entire file into
|
||||||
|
// memory)
|
||||||
|
if (auditConfig.isCaptureFileHash()) {
|
||||||
|
try (InputStream is = file.getInputStream()) {
|
||||||
|
MessageDigest digest = MessageDigest.getInstance("SHA-256");
|
||||||
|
DigestInputStream dis = new DigestInputStream(is, digest);
|
||||||
|
byte[] buffer = new byte[8192];
|
||||||
|
while (dis.read(buffer) != -1) {
|
||||||
|
// Just read through the stream to compute digest
|
||||||
|
}
|
||||||
|
byte[] hashBytes = digest.digest();
|
||||||
|
StringBuilder hexString = new StringBuilder();
|
||||||
|
for (byte b : hashBytes) {
|
||||||
|
hexString.append(String.format("%02x", b));
|
||||||
|
}
|
||||||
|
fileData.put("fileHash", hexString.toString());
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.debug(
|
||||||
|
"Could not calculate file hash for {}: {}",
|
||||||
|
file.getOriginalFilename(),
|
||||||
|
e.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract PDF author if file is a PDF and enabled
|
||||||
|
if (auditConfig.isCapturePdfAuthor()
|
||||||
|
&& "application/pdf".equalsIgnoreCase(file.getContentType())) {
|
||||||
|
try (InputStream is = file.getInputStream();
|
||||||
|
PDDocument doc = pdfDocumentFactory.load(is, true)) {
|
||||||
|
PDDocumentInformation info = doc.getDocumentInformation();
|
||||||
|
if (info != null && info.getAuthor() != null) {
|
||||||
|
fileData.put("pdfAuthor", info.getAuthor());
|
||||||
|
}
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.debug(
|
||||||
|
"Could not extract PDF author from {}: {}",
|
||||||
|
file.getOriginalFilename(),
|
||||||
|
e.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add method arguments to the audit data
|
||||||
|
*
|
||||||
|
* @param data The existing audit data map
|
||||||
|
* @param joinPoint The AspectJ join point
|
||||||
|
* @param auditLevel The current audit level
|
||||||
|
*/
|
||||||
|
public void addMethodArguments(
|
||||||
|
Map<String, Object> data, ProceedingJoinPoint joinPoint, AuditLevel auditLevel) {
|
||||||
|
if (auditLevel.includes(AuditLevel.VERBOSE)) {
|
||||||
|
MethodSignature sig = (MethodSignature) joinPoint.getSignature();
|
||||||
|
String[] names = sig.getParameterNames();
|
||||||
|
Object[] vals = joinPoint.getArgs();
|
||||||
|
if (names != null && vals != null) {
|
||||||
|
IntStream.range(0, names.length)
|
||||||
|
.forEach(
|
||||||
|
i -> {
|
||||||
|
if (vals[i] != null) {
|
||||||
|
// Convert objects to safe string representation
|
||||||
|
data.put("arg_" + names[i], safeToString(vals[i], 500));
|
||||||
|
} else {
|
||||||
|
data.put("arg_" + names[i], null);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Safely convert an object to string with size limiting
|
||||||
|
*
|
||||||
|
* @param obj The object to convert
|
||||||
|
* @param maxLength Maximum length of the resulting string
|
||||||
|
* @return A safe string representation, truncated if needed
|
||||||
|
*/
|
||||||
|
public String safeToString(Object obj, int maxLength) {
|
||||||
|
if (obj == null) {
|
||||||
|
return "null";
|
||||||
|
}
|
||||||
|
|
||||||
|
String result;
|
||||||
|
try {
|
||||||
|
// Handle common types directly to avoid toString() overhead
|
||||||
|
if (obj instanceof String) {
|
||||||
|
result = (String) obj;
|
||||||
|
} else if (obj instanceof Number || obj instanceof Boolean) {
|
||||||
|
result = obj.toString();
|
||||||
|
} else if (obj instanceof byte[]) {
|
||||||
|
result = "[binary data length=" + ((byte[]) obj).length + "]";
|
||||||
|
} else {
|
||||||
|
// For complex objects, use toString but handle exceptions
|
||||||
|
result = obj.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Truncate if necessary
|
||||||
|
if (result != null && result.length() > maxLength) {
|
||||||
|
return StringUtils.truncate(result, maxLength - 3) + "...";
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
} catch (Exception e) {
|
||||||
|
// If toString() fails, return the class name
|
||||||
|
return "[" + obj.getClass().getName() + " - toString() failed]";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Determine if a method should be audited based on config and annotation
|
||||||
|
*
|
||||||
|
* @param method The method to check
|
||||||
|
* @param auditConfig The audit configuration
|
||||||
|
* @return true if the method should be audited
|
||||||
|
*/
|
||||||
|
public boolean shouldAudit(Method method, AuditConfigurationProperties auditConfig) {
|
||||||
|
// First check if we're running Enterprise Edition and audit is enabled - fast path
|
||||||
|
if (!runningEE || !auditConfig.isEnabled()) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for annotation override
|
||||||
|
Audited auditedAnnotation = method.getAnnotation(Audited.class);
|
||||||
|
AuditLevel requiredLevel =
|
||||||
|
(auditedAnnotation != null) ? auditedAnnotation.level() : AuditLevel.BASIC;
|
||||||
|
|
||||||
|
// Check if the required level is enabled
|
||||||
|
return auditConfig.getAuditLevel().includes(requiredLevel);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add timing and response status data to the audit record
|
||||||
|
*
|
||||||
|
* @param data The audit data to add to
|
||||||
|
* @param startTime The start time in milliseconds
|
||||||
|
* @param response The HTTP response (may be null for non-HTTP methods)
|
||||||
|
* @param level The current audit level
|
||||||
|
* @param isHttpRequest Whether this is an HTTP request (controller) or a regular method call
|
||||||
|
*/
|
||||||
|
public void addTimingData(
|
||||||
|
Map<String, Object> data,
|
||||||
|
long startTime,
|
||||||
|
HttpServletResponse response,
|
||||||
|
AuditLevel level,
|
||||||
|
boolean isHttpRequest) {
|
||||||
|
if (level.includes(AuditLevel.STANDARD)) {
|
||||||
|
// For HTTP requests, let ControllerAuditAspect handle timing separately
|
||||||
|
// For non-HTTP methods, add execution time here
|
||||||
|
if (!isHttpRequest) {
|
||||||
|
data.put("latencyMs", System.currentTimeMillis() - startTime);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add HTTP status code if available
|
||||||
|
if (response != null) {
|
||||||
|
try {
|
||||||
|
data.put("statusCode", response.getStatus());
|
||||||
|
} catch (Exception e) {
|
||||||
|
// Ignore - response might be in an inconsistent state
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve the event type to use for auditing, considering annotations and context
|
||||||
|
*
|
||||||
|
* @param method The method being audited
|
||||||
|
* @param controller The controller class
|
||||||
|
* @param path The request path (may be null for non-HTTP methods)
|
||||||
|
* @param httpMethod The HTTP method (may be null for non-HTTP methods)
|
||||||
|
* @param annotation The @Audited annotation (may be null)
|
||||||
|
* @return The resolved event type (never null)
|
||||||
|
*/
|
||||||
|
public AuditEventType resolveEventType(
|
||||||
|
Method method,
|
||||||
|
Class<?> controller,
|
||||||
|
String path,
|
||||||
|
String httpMethod,
|
||||||
|
Audited annotation) {
|
||||||
|
// First check if we have an explicit annotation
|
||||||
|
if (annotation != null && annotation.type() != AuditEventType.HTTP_REQUEST) {
|
||||||
|
return annotation.type();
|
||||||
|
}
|
||||||
|
|
||||||
|
// For HTTP methods, infer based on controller and path
|
||||||
|
if (httpMethod != null && path != null) {
|
||||||
|
String cls = controller.getSimpleName().toLowerCase(Locale.ROOT);
|
||||||
|
String pkg = controller.getPackage().getName().toLowerCase(Locale.ROOT);
|
||||||
|
|
||||||
|
if ("GET".equals(httpMethod)) {
|
||||||
|
// Categorize GET requests as UI_DATA (UI data fetches)
|
||||||
|
// API endpoints use POST/PUT/DELETE, or are specific operational endpoints
|
||||||
|
if (isUiDataEndpoint(path)) {
|
||||||
|
return AuditEventType.UI_DATA;
|
||||||
|
}
|
||||||
|
return AuditEventType.HTTP_REQUEST;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (cls.contains("user")
|
||||||
|
|| cls.contains("auth")
|
||||||
|
|| pkg.contains("auth")
|
||||||
|
|| path.startsWith("/user")
|
||||||
|
|| path.startsWith("/login")) {
|
||||||
|
return AuditEventType.USER_PROFILE_UPDATE;
|
||||||
|
} else if (cls.contains("admin")
|
||||||
|
|| path.startsWith("/admin")
|
||||||
|
|| path.startsWith("/settings")) {
|
||||||
|
return AuditEventType.SETTINGS_CHANGED;
|
||||||
|
} else if (cls.contains("file")
|
||||||
|
|| path.startsWith("/file")
|
||||||
|
|| RegexPatternUtils.getInstance()
|
||||||
|
.getUploadDownloadPathPattern()
|
||||||
|
.matcher(path)
|
||||||
|
.matches()) {
|
||||||
|
return AuditEventType.FILE_OPERATION;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Default for non-HTTP methods or when no specific match
|
||||||
|
return AuditEventType.PDF_PROCESS;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Determine the appropriate audit level to use
|
||||||
|
*
|
||||||
|
* @param method The method to check
|
||||||
|
* @param defaultLevel The default level to use if no annotation present
|
||||||
|
* @param auditConfig The audit configuration
|
||||||
|
* @return The audit level to use
|
||||||
|
*/
|
||||||
|
public AuditLevel getEffectiveAuditLevel(
|
||||||
|
Method method, AuditLevel defaultLevel, AuditConfigurationProperties auditConfig) {
|
||||||
|
Audited auditedAnnotation = method.getAnnotation(Audited.class);
|
||||||
|
if (auditedAnnotation != null) {
|
||||||
|
// Method has @Audited - use its level
|
||||||
|
return auditedAnnotation.level();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use default level (typically from global config)
|
||||||
|
return defaultLevel;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Determine the appropriate audit event type to use
|
||||||
|
*
|
||||||
|
* @param method The method being audited
|
||||||
|
* @param controller The controller class
|
||||||
|
* @param path The request path
|
||||||
|
* @param httpMethod The HTTP method
|
||||||
|
* @return The determined audit event type
|
||||||
|
*/
|
||||||
|
public AuditEventType determineAuditEventType(
|
||||||
|
Method method, Class<?> controller, String path, String httpMethod) {
|
||||||
|
// First check for explicit annotation
|
||||||
|
Audited auditedAnnotation = method.getAnnotation(Audited.class);
|
||||||
|
if (auditedAnnotation != null) {
|
||||||
|
return auditedAnnotation.type();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Otherwise infer from controller and path
|
||||||
|
String cls = controller.getSimpleName().toLowerCase(Locale.ROOT);
|
||||||
|
String pkg = controller.getPackage().getName().toLowerCase(Locale.ROOT);
|
||||||
|
|
||||||
|
if ("GET".equals(httpMethod)) return AuditEventType.HTTP_REQUEST;
|
||||||
|
|
||||||
|
if (cls.contains("user")
|
||||||
|
|| cls.contains("auth")
|
||||||
|
|| pkg.contains("auth")
|
||||||
|
|| path.startsWith("/user")
|
||||||
|
|| path.startsWith("/login")) {
|
||||||
|
return AuditEventType.USER_PROFILE_UPDATE;
|
||||||
|
} else if (cls.contains("admin")
|
||||||
|
|| path.startsWith("/admin")
|
||||||
|
|| path.startsWith("/settings")) {
|
||||||
|
return AuditEventType.SETTINGS_CHANGED;
|
||||||
|
} else if (cls.contains("file")
|
||||||
|
|| path.startsWith("/file")
|
||||||
|
|| RegexPatternUtils.getInstance()
|
||||||
|
.getUploadDownloadPathPattern()
|
||||||
|
.matcher(path)
|
||||||
|
.matches()) {
|
||||||
|
return AuditEventType.FILE_OPERATION;
|
||||||
|
} else {
|
||||||
|
return AuditEventType.PDF_PROCESS;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the current HTTP request if available
|
||||||
|
*
|
||||||
|
* @return The current request or null if not in a request context
|
||||||
|
*/
|
||||||
|
public HttpServletRequest getCurrentRequest() {
|
||||||
|
ServletRequestAttributes attrs =
|
||||||
|
(ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
|
||||||
|
return attrs != null ? attrs.getRequest() : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a GET request is for a static resource
|
||||||
|
*
|
||||||
|
* @param request The HTTP request
|
||||||
|
* @return true if this is a static resource request
|
||||||
|
*/
|
||||||
|
public boolean isStaticResourceRequest(HttpServletRequest request) {
|
||||||
|
return request != null
|
||||||
|
&& !RequestUriUtils.isTrackableResource(
|
||||||
|
request.getContextPath(), request.getRequestURI());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a GET request is a continuous polling call that should be excluded from STANDARD
|
||||||
|
* level
|
||||||
|
*
|
||||||
|
* @param request The HTTP request
|
||||||
|
* @return true if this is a polling/continuous call that should be excluded from STANDARD level
|
||||||
|
*/
|
||||||
|
public boolean isPollingCall(HttpServletRequest request) {
|
||||||
|
if (request == null) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
String path = request.getRequestURI();
|
||||||
|
String method = request.getMethod();
|
||||||
|
|
||||||
|
// Only filter GET requests
|
||||||
|
if (!"GET".equalsIgnoreCase(method)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Polling endpoints excluded from STANDARD level auditing.
|
||||||
|
// Use exact/prefix matching to avoid accidental exclusions on unrelated paths.
|
||||||
|
return path.equals("/api/v1/auth/me")
|
||||||
|
|| path.equals("/api/v1/app-config")
|
||||||
|
|| path.equals("/api/v1/footer-info")
|
||||||
|
|| path.equals("/api/v1/admin/license-info")
|
||||||
|
|| path.equals("/api/v1/endpoints-availability")
|
||||||
|
|| path.equals("/health")
|
||||||
|
|| path.startsWith("/health/")
|
||||||
|
|| path.equals("/metrics")
|
||||||
|
|| path.startsWith("/metrics/")
|
||||||
|
|| path.equals("/actuator/health")
|
||||||
|
|| path.startsWith("/actuator/health/")
|
||||||
|
|| path.equals("/actuator/metrics")
|
||||||
|
|| path.startsWith("/actuator/metrics/");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========== HELPER METHODS ==========
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if an endpoint is an API endpoint. API endpoints match /api/v1/* pattern but exclude
|
||||||
|
* /api/v1/auth/*, /api/v1/ui-data/*, /api/v1/proprietary/ui-data/*, /api/v1/config/*, and
|
||||||
|
* /api/v1/admin/license-info. Everything else is considered "UI".
|
||||||
|
*
|
||||||
|
* @param endpoint The endpoint path to check
|
||||||
|
* @return true if this is an API endpoint, false if it's a UI endpoint
|
||||||
|
*/
|
||||||
|
private boolean isUiDataEndpoint(String endpoint) {
|
||||||
|
if (endpoint == null) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// UI data endpoints include auth, settings, config, user/team management, and UI data
|
||||||
|
// fetches
|
||||||
|
return endpoint.startsWith("/api/v1/auth/")
|
||||||
|
|| endpoint.startsWith("/api/v1/ui-data/")
|
||||||
|
|| endpoint.startsWith("/api/v1/proprietary/ui-data/")
|
||||||
|
|| endpoint.startsWith("/api/v1/config/")
|
||||||
|
|| endpoint.startsWith("/api/v1/admin/settings/")
|
||||||
|
|| endpoint.startsWith("/api/v1/user/")
|
||||||
|
|| endpoint.startsWith("/api/v1/users/")
|
||||||
|
|| endpoint.equals("/api/v1/admin/license-info")
|
||||||
|
|| endpoint.equals("/login");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if operation results (return values) should be captured
|
||||||
|
*
|
||||||
|
* @return true if captureOperationResults is enabled in config
|
||||||
|
*/
|
||||||
|
public boolean shouldCaptureOperationResults() {
|
||||||
|
return auditConfig.isCaptureOperationResults();
|
||||||
|
}
|
||||||
|
|
||||||
/** Get the current authenticated username or "system" if none */
|
/** Get the current authenticated username or "system" if none */
|
||||||
private String getCurrentUsername() {
|
private String getCurrentUsername() {
|
||||||
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
|
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";
|
return (auth != null && auth.getName() != null) ? auth.getName() : "system";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Captures the current principal for later use (avoids SecurityContext issues in async/thread
|
||||||
|
* changes). Public so audit aspects can capture early before thread context changes.
|
||||||
|
*/
|
||||||
|
public String captureCurrentPrincipal() {
|
||||||
|
String principal = getCurrentUsername();
|
||||||
|
return principal;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Captures the current origin for later use (avoids SecurityContext issues in async/thread
|
||||||
|
* changes). Public so audit aspects can capture early before thread context changes.
|
||||||
|
*/
|
||||||
|
public String captureCurrentOrigin() {
|
||||||
|
String origin = determineOrigin();
|
||||||
|
return origin;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Determines the origin of the request: API (X-API-KEY), WEB (JWT), or SYSTEM (no auth).
|
||||||
|
* IMPORTANT: This must be called in the request thread before async execution.
|
||||||
|
*
|
||||||
|
* @return "API", "WEB", or "SYSTEM"
|
||||||
|
*/
|
||||||
|
private String determineOrigin() {
|
||||||
|
try {
|
||||||
|
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
|
||||||
|
|
||||||
|
// Check if authenticated via API key
|
||||||
|
if (auth instanceof ApiKeyAuthenticationToken) {
|
||||||
|
return "API";
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if authenticated via JWT (web user)
|
||||||
|
if (auth != null && auth.isAuthenticated() && !"anonymousUser".equals(auth.getName())) {
|
||||||
|
return "WEB";
|
||||||
|
}
|
||||||
|
|
||||||
|
// Refresh endpoint may still be anonymous in SecurityContext; infer origin from a
|
||||||
|
// verified JWT if present.
|
||||||
|
HttpServletRequest req = getCurrentRequest();
|
||||||
|
String refreshOrigin = extractRefreshTokenOrigin(req);
|
||||||
|
if (refreshOrigin != null) {
|
||||||
|
return refreshOrigin;
|
||||||
|
}
|
||||||
|
|
||||||
|
// System or unauthenticated
|
||||||
|
return "SYSTEM";
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.debug("Could not determine origin for audit event", e);
|
||||||
|
return "SYSTEM";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private String extractRefreshTokenSubject(HttpServletRequest request) {
|
||||||
|
if (!isRefreshEndpoint(request)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
String token = jwtService.extractToken(request);
|
||||||
|
if (StringUtils.isBlank(token)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
String subject = jwtService.extractUsernameAllowExpired(token);
|
||||||
|
return StringUtils.isNotBlank(subject) ? subject : null;
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.debug("Could not extract refresh token subject for audit attribution", e);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private String extractRefreshTokenOrigin(HttpServletRequest request) {
|
||||||
|
if (!isRefreshEndpoint(request)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
String token = jwtService.extractToken(request);
|
||||||
|
if (StringUtils.isBlank(token)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
Map<String, Object> claims = jwtService.extractClaimsAllowExpired(token);
|
||||||
|
Object authType = claims.get("authType");
|
||||||
|
if (authType != null && "API".equalsIgnoreCase(String.valueOf(authType))) {
|
||||||
|
return "API";
|
||||||
|
}
|
||||||
|
// Any verified non-API JWT refresh request is web/SSO user traffic.
|
||||||
|
return "WEB";
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.debug("Could not extract refresh token origin for audit attribution", e);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private boolean isRefreshEndpoint(HttpServletRequest request) {
|
||||||
|
if (request == null) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
String path = request.getRequestURI();
|
||||||
|
return StringUtils.isNotBlank(path) && path.endsWith("/api/v1/auth/refresh");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extract client IP address from the request, preferring X-Forwarded-For header for proxy/load
|
||||||
|
* balancer support. IMPORTANT: Must be called in the request thread before async execution to
|
||||||
|
* preserve the IP.
|
||||||
|
*
|
||||||
|
* @param request The HTTP request
|
||||||
|
* @return The client IP address, or null if not available
|
||||||
|
*/
|
||||||
|
public String extractClientIp(HttpServletRequest request) {
|
||||||
|
if (request == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try X-Forwarded-For first (set by proxies/load balancers)
|
||||||
|
String forwardedFor = request.getHeader("X-Forwarded-For");
|
||||||
|
if (StringUtils.isNotBlank(forwardedFor)) {
|
||||||
|
// X-Forwarded-For can contain multiple IPs, take the first one (original client)
|
||||||
|
String[] ips = forwardedFor.split(",");
|
||||||
|
return ips[0].trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try X-Real-IP (used by some proxies)
|
||||||
|
String realIp = request.getHeader("X-Real-IP");
|
||||||
|
if (StringUtils.isNotBlank(realIp)) {
|
||||||
|
return realIp;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fall back to remote address
|
||||||
|
return request.getRemoteAddr();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ public final class SecretMasker {
|
|||||||
private static final Pattern SENSITIVE =
|
private static final Pattern SENSITIVE =
|
||||||
RegexPatternUtils.getInstance()
|
RegexPatternUtils.getInstance()
|
||||||
.getPattern(
|
.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() {}
|
private SecretMasker() {}
|
||||||
|
|
||||||
|
|||||||
Generated
+391
@@ -72,6 +72,7 @@
|
|||||||
"react-i18next": "^15.7.3",
|
"react-i18next": "^15.7.3",
|
||||||
"react-rnd": "^10.5.2",
|
"react-rnd": "^10.5.2",
|
||||||
"react-router-dom": "^7.9.1",
|
"react-router-dom": "^7.9.1",
|
||||||
|
"recharts": "^3.7.0",
|
||||||
"signature_pad": "^5.0.4",
|
"signature_pad": "^5.0.4",
|
||||||
"smol-toml": "^1.4.2",
|
"smol-toml": "^1.4.2",
|
||||||
"tailwindcss": "^4.1.13",
|
"tailwindcss": "^4.1.13",
|
||||||
@@ -3013,6 +3014,42 @@
|
|||||||
"react": "16.x || 17.x || 18.x || 19.x"
|
"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": {
|
"node_modules/@rolldown/pluginutils": {
|
||||||
"version": "1.0.0-rc.2",
|
"version": "1.0.0-rc.2",
|
||||||
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.2.tgz",
|
"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"
|
"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": {
|
"node_modules/@stripe/react-stripe-js": {
|
||||||
"version": "4.0.2",
|
"version": "4.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/@stripe/react-stripe-js/-/react-stripe-js-4.0.2.tgz",
|
"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"
|
"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": {
|
"node_modules/@types/deep-eql": {
|
||||||
"version": "4.0.2",
|
"version": "4.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz",
|
||||||
@@ -4637,6 +4749,12 @@
|
|||||||
"integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==",
|
"integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==",
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/@types/ws": {
|
||||||
"version": "8.18.1",
|
"version": "8.18.1",
|
||||||
"resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz",
|
"resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz",
|
||||||
@@ -6329,6 +6447,127 @@
|
|||||||
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
|
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/data-uri-to-buffer": {
|
||||||
"version": "6.0.2",
|
"version": "6.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-6.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-6.0.2.tgz",
|
||||||
@@ -6405,6 +6644,12 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/decompress-response": {
|
||||||
"version": "10.0.0",
|
"version": "10.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-10.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-10.0.0.tgz",
|
||||||
@@ -6963,6 +7208,16 @@
|
|||||||
"node": ">= 0.4"
|
"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": {
|
"node_modules/esbuild": {
|
||||||
"version": "0.27.3",
|
"version": "0.27.3",
|
||||||
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.3.tgz",
|
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.3.tgz",
|
||||||
@@ -8116,6 +8371,16 @@
|
|||||||
"integrity": "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==",
|
"integrity": "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==",
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/import-fresh": {
|
||||||
"version": "3.3.1",
|
"version": "3.3.1",
|
||||||
"resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz",
|
"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": "^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": {
|
"node_modules/ip-address": {
|
||||||
"version": "10.1.0",
|
"version": "10.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.1.0.tgz",
|
"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"
|
"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": {
|
"node_modules/react-remove-scroll": {
|
||||||
"version": "2.7.2",
|
"version": "2.7.2",
|
||||||
"resolved": "https://registry.npmjs.org/react-remove-scroll/-/react-remove-scroll-2.7.2.tgz",
|
"resolved": "https://registry.npmjs.org/react-remove-scroll/-/react-remove-scroll-2.7.2.tgz",
|
||||||
@@ -11112,6 +11409,42 @@
|
|||||||
"node": ">=8.10.0"
|
"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": {
|
"node_modules/redent": {
|
||||||
"version": "3.0.0",
|
"version": "3.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz",
|
||||||
@@ -11126,6 +11459,21 @@
|
|||||||
"node": ">=8"
|
"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": {
|
"node_modules/require-directory": {
|
||||||
"version": "2.1.1",
|
"version": "2.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz",
|
||||||
@@ -11174,6 +11522,12 @@
|
|||||||
"node": ">=10.13.0"
|
"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": {
|
"node_modules/resize-observer-polyfill": {
|
||||||
"version": "1.5.1",
|
"version": "1.5.1",
|
||||||
"resolved": "https://registry.npmjs.org/resize-observer-polyfill/-/resize-observer-polyfill-1.5.1.tgz",
|
"resolved": "https://registry.npmjs.org/resize-observer-polyfill/-/resize-observer-polyfill-1.5.1.tgz",
|
||||||
@@ -12195,6 +12549,12 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "Apache-2.0"
|
"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": {
|
"node_modules/tinybench": {
|
||||||
"version": "2.9.0",
|
"version": "2.9.0",
|
||||||
"resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz",
|
"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": {
|
"node_modules/util-deprecate": {
|
||||||
"version": "1.0.2",
|
"version": "1.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
|
||||||
@@ -12685,6 +13054,28 @@
|
|||||||
"spdx-expression-parse": "^3.0.0"
|
"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": {
|
"node_modules/visit-values": {
|
||||||
"version": "2.0.0",
|
"version": "2.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/visit-values/-/visit-values-2.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/visit-values/-/visit-values-2.0.0.tgz",
|
||||||
|
|||||||
@@ -68,6 +68,7 @@
|
|||||||
"react-i18next": "^15.7.3",
|
"react-i18next": "^15.7.3",
|
||||||
"react-rnd": "^10.5.2",
|
"react-rnd": "^10.5.2",
|
||||||
"react-router-dom": "^7.9.1",
|
"react-router-dom": "^7.9.1",
|
||||||
|
"recharts": "^3.7.0",
|
||||||
"signature_pad": "^5.0.4",
|
"signature_pad": "^5.0.4",
|
||||||
"smol-toml": "^1.4.2",
|
"smol-toml": "^1.4.2",
|
||||||
"tailwindcss": "^4.1.13",
|
"tailwindcss": "^4.1.13",
|
||||||
|
|||||||
@@ -1164,19 +1164,43 @@ title = "Security"
|
|||||||
|
|
||||||
[admin.settings.security.audit]
|
[admin.settings.security.audit]
|
||||||
label = "Audit Logging"
|
label = "Audit Logging"
|
||||||
|
showDetails = "Show audit level details"
|
||||||
|
hideDetails = "Hide audit level details"
|
||||||
|
|
||||||
[admin.settings.security.audit.enabled]
|
[admin.settings.security.audit.enabled]
|
||||||
description = "Track user actions and system events for compliance and security monitoring"
|
description = "Track user actions and system events for compliance and security monitoring"
|
||||||
label = "Enable Audit Logging"
|
label = "Enable Audit Logging"
|
||||||
|
|
||||||
[admin.settings.security.audit.level]
|
[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"
|
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]
|
[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)"
|
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]
|
[admin.settings.security.csrfDisabled]
|
||||||
description = "Disable Cross-Site Request Forgery protection (not recommended)"
|
description = "Disable Cross-Site Request Forgery protection (not recommended)"
|
||||||
label = "Disable CSRF Protection"
|
label = "Disable CSRF Protection"
|
||||||
@@ -1540,72 +1564,152 @@ tags = "attachments,add,remove,embed,file"
|
|||||||
title = "Add Attachments"
|
title = "Add Attachments"
|
||||||
|
|
||||||
[audit]
|
[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"
|
disabled = "Audit logging is disabled"
|
||||||
disabledMessage = "Enable audit logging in your application configuration to track system events."
|
disabledMessage = "Enable audit logging in your application configuration to track system events."
|
||||||
enterpriseRequired = "Enterprise License Required"
|
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."
|
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"
|
notAvailable = "Audit system not available"
|
||||||
notAvailableMessage = "The audit system is not configured or 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]
|
[audit.error]
|
||||||
title = "Error loading audit system"
|
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]
|
[audit.export]
|
||||||
clearFilters = "Clear"
|
clearFilters = "Clear"
|
||||||
description = "Export audit events to CSV or JSON format. Use filters to limit the exported data."
|
description = "Export audit events to CSV or JSON format. Use filters to limit the exported data."
|
||||||
endDate = "End date"
|
endDate = "End date"
|
||||||
error = "Failed to export data"
|
error = "Failed to export data"
|
||||||
exportButton = "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"
|
filterByType = "Filter by type"
|
||||||
filterByUser = "Filter by user"
|
filterByUser = "Filter by user"
|
||||||
filters = "Filters (Optional)"
|
filters = "Filters (Optional)"
|
||||||
format = "Export Format"
|
format = "Export Format"
|
||||||
|
selectFields = "Select Fields to Include"
|
||||||
startDate = "Start date"
|
startDate = "Start date"
|
||||||
title = "Export Audit Data"
|
title = "Export Audit Data"
|
||||||
|
verboseRequired = "Requires VERBOSE audit level"
|
||||||
|
|
||||||
[audit.systemStatus]
|
[audit.systemStatus]
|
||||||
|
autoRefresh = "Auto-refresh"
|
||||||
|
autoRefreshLabel = "Auto-refresh every 30s"
|
||||||
|
captureBySettings = "Enable in settings"
|
||||||
|
capturedFields = "Captured Fields"
|
||||||
|
date = "Date"
|
||||||
days = "days"
|
days = "days"
|
||||||
disabled = "Disabled"
|
disabled = "Disabled"
|
||||||
|
documentName = "Document Name"
|
||||||
enabled = "Enabled"
|
enabled = "Enabled"
|
||||||
|
fileHash = "File Hash"
|
||||||
level = "Audit Level"
|
level = "Audit Level"
|
||||||
|
pdfAuthor = "PDF Author"
|
||||||
retention = "Retention Period"
|
retention = "Retention Period"
|
||||||
status = "Audit Logging"
|
status = "Audit Logging"
|
||||||
title = "System Status"
|
title = "System Status"
|
||||||
|
tool = "Tool"
|
||||||
totalEvents = "Total Events"
|
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]
|
[audit.tabs]
|
||||||
|
clearData = "Clear Data"
|
||||||
dashboard = "Dashboard"
|
dashboard = "Dashboard"
|
||||||
events = "Audit Events"
|
events = "Audit Events"
|
||||||
export = "Export"
|
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]
|
[auth]
|
||||||
accessDenied = "Access Denied"
|
accessDenied = "Access Denied"
|
||||||
insufficientPermissions = "You do not have permission to perform this action."
|
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"
|
title = "URL To PDF"
|
||||||
|
|
||||||
[usage]
|
[usage]
|
||||||
|
aboutUsageAnalytics = "About Usage Analytics"
|
||||||
|
configureSettings = "Configure Analytics Settings"
|
||||||
error = "Error loading usage statistics"
|
error = "Error loading usage statistics"
|
||||||
noData = "No data available"
|
noData = "No data available"
|
||||||
noDataMessage = "No usage statistics are currently 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]
|
[usage.chart]
|
||||||
title = "Endpoint Usage Chart"
|
title = "Endpoint Usage Chart"
|
||||||
|
|||||||
@@ -2,12 +2,12 @@
|
|||||||
.modal-container {
|
.modal-container {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 0;
|
gap: 0;
|
||||||
height: 37.5rem; /* 600px */
|
height: 45rem; /* 720px */
|
||||||
}
|
}
|
||||||
|
|
||||||
.modal-nav {
|
.modal-nav {
|
||||||
width: 15rem; /* 240px */
|
width: 15rem; /* 240px */
|
||||||
height: 37.5rem; /* 600px */
|
height: 45rem; /* 720px */
|
||||||
border-top-left-radius: 0.75rem; /* 12px */
|
border-top-left-radius: 0.75rem; /* 12px */
|
||||||
border-bottom-left-radius: 0.75rem; /* 12px */
|
border-bottom-left-radius: 0.75rem; /* 12px */
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
@@ -108,7 +108,7 @@
|
|||||||
|
|
||||||
.modal-content {
|
.modal-content {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
height: 37.5rem; /* 600px */
|
height: 45rem; /* 720px */
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
|
|||||||
@@ -118,7 +118,7 @@ const AppConfigModalInner: React.FC<AppConfigModalProps> = ({ opened, onClose })
|
|||||||
opened={opened}
|
opened={opened}
|
||||||
onClose={handleClose}
|
onClose={handleClose}
|
||||||
title={null}
|
title={null}
|
||||||
size={isMobile ? "100%" : 980}
|
size={isMobile ? "100%" : 1100}
|
||||||
centered
|
centered
|
||||||
radius="lg"
|
radius="lg"
|
||||||
withCloseButton={false}
|
withCloseButton={false}
|
||||||
|
|||||||
@@ -5,6 +5,10 @@ export interface AuditSystemStatus {
|
|||||||
level: string;
|
level: string;
|
||||||
retentionDays: number;
|
retentionDays: number;
|
||||||
totalEvents: number;
|
totalEvents: number;
|
||||||
|
pdfMetadataEnabled: boolean;
|
||||||
|
captureFileHash: boolean;
|
||||||
|
capturePdfAuthor: boolean;
|
||||||
|
captureOperationResults: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AuditEvent {
|
export interface AuditEvent {
|
||||||
@@ -36,12 +40,32 @@ export interface AuditChartsData {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface AuditFilters {
|
export interface AuditFilters {
|
||||||
eventType?: string;
|
eventType?: string | string[];
|
||||||
username?: string;
|
username?: string | string[];
|
||||||
startDate?: string;
|
startDate?: string;
|
||||||
endDate?: string;
|
endDate?: string;
|
||||||
|
outcome?: string;
|
||||||
page?: number;
|
page?: number;
|
||||||
pageSize?: 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<string, number>;
|
||||||
|
eventsByUser: Record<string, number>;
|
||||||
|
topTools: Record<string, number>;
|
||||||
|
hourlyDistribution: Record<string, number>; // "00"–"23" keys
|
||||||
}
|
}
|
||||||
|
|
||||||
const auditService = {
|
const auditService = {
|
||||||
@@ -60,9 +84,23 @@ const auditService = {
|
|||||||
level: data.auditLevel,
|
level: data.auditLevel,
|
||||||
retentionDays: data.retentionDays,
|
retentionDays: data.retentionDays,
|
||||||
totalEvents: 0, // Will be fetched separately
|
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<AuditStats> {
|
||||||
|
const response = await apiClient.get<AuditStats>('/api/v1/proprietary/ui-data/audit-stats', {
|
||||||
|
params: { period: timePeriod },
|
||||||
|
});
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get audit events with pagination and filters
|
* 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(
|
async exportData(
|
||||||
format: 'csv' | 'json',
|
format: 'csv' | 'json',
|
||||||
@@ -112,6 +150,13 @@ const auditService = {
|
|||||||
const response = await apiClient.get<string[]>('/api/v1/proprietary/ui-data/audit-users');
|
const response = await apiClient.get<string[]>('/api/v1/proprietary/ui-data/audit-users');
|
||||||
return response.data;
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clear all audit data from the database (irreversible)
|
||||||
|
*/
|
||||||
|
async clearAllAuditData(): Promise<void> {
|
||||||
|
await apiClient.post('/api/v1/proprietary/ui-data/audit-clear-all', {});
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
export default auditService;
|
export default auditService;
|
||||||
|
|||||||
+83
-8
@@ -1,18 +1,23 @@
|
|||||||
import React, { useState, useEffect } from 'react';
|
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 { useTranslation } from 'react-i18next';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
import auditService, { AuditSystemStatus as AuditStatus } from '@app/services/auditService';
|
import auditService, { AuditSystemStatus as AuditStatus } from '@app/services/auditService';
|
||||||
import AuditSystemStatus from '@app/components/shared/config/configSections/audit/AuditSystemStatus';
|
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 AuditChartsSection from '@app/components/shared/config/configSections/audit/AuditChartsSection';
|
||||||
import AuditEventsTable from '@app/components/shared/config/configSections/audit/AuditEventsTable';
|
import AuditEventsTable from '@app/components/shared/config/configSections/audit/AuditEventsTable';
|
||||||
import AuditExportSection from '@app/components/shared/config/configSections/audit/AuditExportSection';
|
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 { useLoginRequired } from '@app/hooks/useLoginRequired';
|
||||||
import LoginRequiredBanner from '@app/components/shared/config/LoginRequiredBanner';
|
import LoginRequiredBanner from '@app/components/shared/config/LoginRequiredBanner';
|
||||||
import { useAppConfig } from '@app/contexts/AppConfigContext';
|
import { useAppConfig } from '@app/contexts/AppConfigContext';
|
||||||
import EnterpriseRequiredBanner from '@app/components/shared/config/EnterpriseRequiredBanner';
|
import EnterpriseRequiredBanner from '@app/components/shared/config/EnterpriseRequiredBanner';
|
||||||
|
import LocalIcon from '@app/components/shared/LocalIcon';
|
||||||
|
|
||||||
const AdminAuditSection: React.FC = () => {
|
const AdminAuditSection: React.FC = () => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
const navigate = useNavigate();
|
||||||
const { loginEnabled } = useLoginRequired();
|
const { loginEnabled } = useLoginRequired();
|
||||||
const { config } = useAppConfig();
|
const { config } = useAppConfig();
|
||||||
const licenseType = config?.license ?? 'NORMAL';
|
const licenseType = config?.license ?? 'NORMAL';
|
||||||
@@ -21,6 +26,7 @@ const AdminAuditSection: React.FC = () => {
|
|||||||
const [systemStatus, setSystemStatus] = useState<AuditStatus | null>(null);
|
const [systemStatus, setSystemStatus] = useState<AuditStatus | null>(null);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [timePeriod, setTimePeriod] = useState<'day' | 'week' | 'month'>('week');
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const fetchSystemStatus = async () => {
|
const fetchSystemStatus = async () => {
|
||||||
@@ -52,6 +58,10 @@ const AdminAuditSection: React.FC = () => {
|
|||||||
level: 'INFO',
|
level: 'INFO',
|
||||||
retentionDays: 90,
|
retentionDays: 90,
|
||||||
totalEvents: 1234,
|
totalEvents: 1234,
|
||||||
|
pdfMetadataEnabled: true,
|
||||||
|
captureFileHash: true,
|
||||||
|
capturePdfAuthor: true,
|
||||||
|
captureOperationResults: false,
|
||||||
});
|
});
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
@@ -94,6 +104,8 @@ const AdminAuditSection: React.FC = () => {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const isEnabled = loginEnabled && hasEnterpriseLicense;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Stack gap="lg">
|
<Stack gap="lg">
|
||||||
<LoginRequiredBanner show={!loginEnabled} />
|
<LoginRequiredBanner show={!loginEnabled} />
|
||||||
@@ -101,32 +113,95 @@ const AdminAuditSection: React.FC = () => {
|
|||||||
show={!hasEnterpriseLicense}
|
show={!hasEnterpriseLicense}
|
||||||
featureName={t('settings.licensingAnalytics.audit', 'Audit')}
|
featureName={t('settings.licensingAnalytics.audit', 'Audit')}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{/* Info banner about audit settings */}
|
||||||
|
{isEnabled && (
|
||||||
|
<Alert
|
||||||
|
icon={<LocalIcon icon="info" width="1.2rem" height="1.2rem" />}
|
||||||
|
title={t('audit.configureAudit', 'Configure Audit Logging')}
|
||||||
|
color="blue"
|
||||||
|
variant="light"
|
||||||
|
>
|
||||||
|
<Stack gap="xs">
|
||||||
|
<Text size="sm">
|
||||||
|
{t(
|
||||||
|
'audit.configureAuditMessage',
|
||||||
|
'Adjust audit logging level, retention period, and other settings in the Security & Authentication section.'
|
||||||
|
)}
|
||||||
|
</Text>
|
||||||
|
<Button
|
||||||
|
variant="light"
|
||||||
|
size="xs"
|
||||||
|
onClick={() => navigate('/settings/adminSecurity#auditLogging')}
|
||||||
|
rightSection={<LocalIcon icon="arrow-forward" width="0.9rem" height="0.9rem" />}
|
||||||
|
>
|
||||||
|
{t('audit.goToSettings', 'Go to Audit Settings')}
|
||||||
|
</Button>
|
||||||
|
</Stack>
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
<AuditSystemStatus status={systemStatus} />
|
<AuditSystemStatus status={systemStatus} />
|
||||||
|
|
||||||
{systemStatus.enabled ? (
|
{systemStatus?.enabled ? (
|
||||||
<Tabs defaultValue="dashboard">
|
<Tabs defaultValue="dashboard">
|
||||||
<Tabs.List>
|
<Tabs.List>
|
||||||
<Tabs.Tab value="dashboard" disabled={!loginEnabled || !hasEnterpriseLicense}>
|
<Tabs.Tab value="dashboard" disabled={!isEnabled}>
|
||||||
{t('audit.tabs.dashboard', 'Dashboard')}
|
{t('audit.tabs.dashboard', 'Dashboard')}
|
||||||
</Tabs.Tab>
|
</Tabs.Tab>
|
||||||
<Tabs.Tab value="events" disabled={!loginEnabled || !hasEnterpriseLicense}>
|
<Tabs.Tab value="events" disabled={!isEnabled}>
|
||||||
{t('audit.tabs.events', 'Audit Events')}
|
{t('audit.tabs.events', 'Audit Events')}
|
||||||
</Tabs.Tab>
|
</Tabs.Tab>
|
||||||
<Tabs.Tab value="export" disabled={!loginEnabled || !hasEnterpriseLicense}>
|
<Tabs.Tab value="export" disabled={!isEnabled}>
|
||||||
{t('audit.tabs.export', 'Export')}
|
{t('audit.tabs.export', 'Export')}
|
||||||
</Tabs.Tab>
|
</Tabs.Tab>
|
||||||
|
<Tabs.Tab value="clearData" disabled={!isEnabled}>
|
||||||
|
{t('audit.tabs.clearData', 'Clear Data')}
|
||||||
|
</Tabs.Tab>
|
||||||
</Tabs.List>
|
</Tabs.List>
|
||||||
|
|
||||||
<Tabs.Panel value="dashboard" pt="md">
|
<Tabs.Panel value="dashboard" pt="md">
|
||||||
<AuditChartsSection loginEnabled={loginEnabled && hasEnterpriseLicense} />
|
<Stack gap="lg">
|
||||||
|
{/* Stats Cards - Always Visible */}
|
||||||
|
<AuditStatsCards loginEnabled={isEnabled} timePeriod={timePeriod} />
|
||||||
|
|
||||||
|
{/* Charts in Accordion - Collapsible */}
|
||||||
|
<Accordion defaultValue={["events-over-time"]} multiple>
|
||||||
|
<Accordion.Item value="events-over-time">
|
||||||
|
<Accordion.Control>
|
||||||
|
{t('audit.charts.overTime', 'Events Over Time')}
|
||||||
|
</Accordion.Control>
|
||||||
|
<Accordion.Panel>
|
||||||
|
<AuditChartsSection
|
||||||
|
loginEnabled={isEnabled}
|
||||||
|
timePeriod={timePeriod}
|
||||||
|
onTimePeriodChange={setTimePeriod}
|
||||||
|
/>
|
||||||
|
</Accordion.Panel>
|
||||||
|
</Accordion.Item>
|
||||||
|
</Accordion>
|
||||||
|
</Stack>
|
||||||
</Tabs.Panel>
|
</Tabs.Panel>
|
||||||
|
|
||||||
<Tabs.Panel value="events" pt="md">
|
<Tabs.Panel value="events" pt="md">
|
||||||
<AuditEventsTable loginEnabled={loginEnabled && hasEnterpriseLicense} />
|
<AuditEventsTable
|
||||||
|
loginEnabled={isEnabled}
|
||||||
|
captureFileHash={systemStatus?.captureFileHash}
|
||||||
|
capturePdfAuthor={systemStatus?.capturePdfAuthor}
|
||||||
|
/>
|
||||||
</Tabs.Panel>
|
</Tabs.Panel>
|
||||||
|
|
||||||
<Tabs.Panel value="export" pt="md">
|
<Tabs.Panel value="export" pt="md">
|
||||||
<AuditExportSection loginEnabled={loginEnabled && hasEnterpriseLicense} />
|
<AuditExportSection
|
||||||
|
loginEnabled={isEnabled}
|
||||||
|
captureFileHash={systemStatus?.captureFileHash}
|
||||||
|
capturePdfAuthor={systemStatus?.capturePdfAuthor}
|
||||||
|
captureOperationResults={systemStatus?.captureOperationResults}
|
||||||
|
/>
|
||||||
|
</Tabs.Panel>
|
||||||
|
|
||||||
|
<Tabs.Panel value="clearData" pt="md">
|
||||||
|
<AuditClearDataSection loginEnabled={isEnabled} />
|
||||||
</Tabs.Panel>
|
</Tabs.Panel>
|
||||||
</Tabs>
|
</Tabs>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
+65
-1
@@ -33,6 +33,9 @@ interface SecuritySettingsData {
|
|||||||
enabled?: boolean;
|
enabled?: boolean;
|
||||||
level?: number;
|
level?: number;
|
||||||
retentionDays?: number;
|
retentionDays?: number;
|
||||||
|
captureFileHash?: boolean;
|
||||||
|
capturePdfAuthor?: boolean;
|
||||||
|
captureOperationResults?: boolean;
|
||||||
};
|
};
|
||||||
html?: {
|
html?: {
|
||||||
urlSecurity?: {
|
urlSecurity?: {
|
||||||
@@ -144,7 +147,10 @@ export default function AdminSecuritySection() {
|
|||||||
// Premium audit settings
|
// Premium audit settings
|
||||||
'premium.enterpriseFeatures.audit.enabled': audit?.enabled,
|
'premium.enterpriseFeatures.audit.enabled': audit?.enabled,
|
||||||
'premium.enterpriseFeatures.audit.level': audit?.level,
|
'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
|
// System HTML settings
|
||||||
@@ -553,6 +559,64 @@ export default function AdminSecuritySection() {
|
|||||||
disabled={!loginEnabled}
|
disabled={!loginEnabled}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<Alert color="yellow" icon={<LocalIcon icon="info" />} 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.')}
|
||||||
|
</Alert>
|
||||||
|
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||||
|
<div>
|
||||||
|
<Text fw={500} size="sm">{t('admin.settings.security.audit.captureFileHash.label', 'Capture File Hash')}</Text>
|
||||||
|
<Text size="xs" c="dimmed" mt={4}>
|
||||||
|
{t('admin.settings.security.audit.captureFileHash.description', 'Store MD5 hash of processed files for audit trail verification')}
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
<Group gap="xs">
|
||||||
|
<Switch
|
||||||
|
name="audit_captureFileHash"
|
||||||
|
checked={settings?.audit?.captureFileHash || false}
|
||||||
|
onChange={(e) => setSettings({ ...settings, audit: { ...settings?.audit, captureFileHash: e.target.checked } })}
|
||||||
|
disabled={!loginEnabled}
|
||||||
|
/>
|
||||||
|
<PendingBadge show={isFieldPending('audit.captureFileHash')} />
|
||||||
|
</Group>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||||
|
<div>
|
||||||
|
<Text fw={500} size="sm">{t('admin.settings.security.audit.capturePdfAuthor.label', 'Capture PDF Author')}</Text>
|
||||||
|
<Text size="xs" c="dimmed" mt={4}>
|
||||||
|
{t('admin.settings.security.audit.capturePdfAuthor.description', 'Extract author field from PDF documents during processing')}
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
<Group gap="xs">
|
||||||
|
<Switch
|
||||||
|
name="audit_capturePdfAuthor"
|
||||||
|
checked={settings?.audit?.capturePdfAuthor || false}
|
||||||
|
onChange={(e) => setSettings({ ...settings, audit: { ...settings?.audit, capturePdfAuthor: e.target.checked } })}
|
||||||
|
disabled={!loginEnabled}
|
||||||
|
/>
|
||||||
|
<PendingBadge show={isFieldPending('audit.capturePdfAuthor')} />
|
||||||
|
</Group>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||||
|
<div>
|
||||||
|
<Text fw={500} size="sm">{t('admin.settings.security.audit.captureOperationResults.label', 'Capture Operation Results')}</Text>
|
||||||
|
<Text size="xs" c="dimmed" mt={4}>
|
||||||
|
{t('admin.settings.security.audit.captureOperationResults.description', 'Store output file information and processing results in audit logs')}
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
<Group gap="xs">
|
||||||
|
<Switch
|
||||||
|
name="audit_captureOperationResults"
|
||||||
|
checked={settings?.audit?.captureOperationResults || false}
|
||||||
|
onChange={(e) => setSettings({ ...settings, audit: { ...settings?.audit, captureOperationResults: e.target.checked } })}
|
||||||
|
disabled={!loginEnabled}
|
||||||
|
/>
|
||||||
|
<PendingBadge show={isFieldPending('audit.captureOperationResults')} />
|
||||||
|
</Group>
|
||||||
|
</div>
|
||||||
</Stack>
|
</Stack>
|
||||||
</Paper>
|
</Paper>
|
||||||
|
|
||||||
|
|||||||
+41
-11
@@ -1,15 +1,7 @@
|
|||||||
import React, { useState, useEffect, useCallback } from 'react';
|
import React, { useState, useEffect, useCallback } from 'react';
|
||||||
import {
|
import { Stack, Group, Text, Button, SegmentedControl, Loader, Alert, Card } from '@mantine/core';
|
||||||
Stack,
|
|
||||||
Group,
|
|
||||||
Text,
|
|
||||||
Button,
|
|
||||||
SegmentedControl,
|
|
||||||
Loader,
|
|
||||||
Alert,
|
|
||||||
Card,
|
|
||||||
} from '@mantine/core';
|
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
import usageAnalyticsService, { EndpointStatisticsResponse } from '@app/services/usageAnalyticsService';
|
import usageAnalyticsService, { EndpointStatisticsResponse } from '@app/services/usageAnalyticsService';
|
||||||
import UsageAnalyticsChart from '@app/components/shared/config/configSections/usage/UsageAnalyticsChart';
|
import UsageAnalyticsChart from '@app/components/shared/config/configSections/usage/UsageAnalyticsChart';
|
||||||
import UsageAnalyticsTable from '@app/components/shared/config/configSections/usage/UsageAnalyticsTable';
|
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 AdminUsageSection: React.FC = () => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
const navigate = useNavigate();
|
||||||
const { loginEnabled } = useLoginRequired();
|
const { loginEnabled } = useLoginRequired();
|
||||||
const { config } = useAppConfig();
|
const { config } = useAppConfig();
|
||||||
const licenseType = config?.license ?? 'NORMAL';
|
const licenseType = config?.license ?? 'NORMAL';
|
||||||
@@ -30,7 +23,7 @@ const AdminUsageSection: React.FC = () => {
|
|||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [displayMode, setDisplayMode] = useState<'top10' | 'top20' | 'all'>('top10');
|
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 buildDemoUsageData = useCallback((): EndpointStatisticsResponse => {
|
||||||
const totalVisits = 15847;
|
const totalVisits = 15847;
|
||||||
@@ -180,6 +173,43 @@ const AdminUsageSection: React.FC = () => {
|
|||||||
featureName={t('settings.licensingAnalytics.usageAnalytics', 'Usage Analytics')}
|
featureName={t('settings.licensingAnalytics.usageAnalytics', 'Usage Analytics')}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{/* Info banner about usage analytics and audit relationship */}
|
||||||
|
{loginEnabled && hasEnterpriseLicense && (
|
||||||
|
<Alert
|
||||||
|
icon={<LocalIcon icon="info" width="1.2rem" height="1.2rem" />}
|
||||||
|
title={t('usage.aboutUsageAnalytics', 'About Usage Analytics')}
|
||||||
|
color="cyan"
|
||||||
|
variant="light"
|
||||||
|
>
|
||||||
|
<Stack gap="xs">
|
||||||
|
<Text size="sm">
|
||||||
|
{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.'
|
||||||
|
)}
|
||||||
|
</Text>
|
||||||
|
<Group gap="sm">
|
||||||
|
<Button
|
||||||
|
variant="light"
|
||||||
|
size="xs"
|
||||||
|
onClick={() => navigate('/settings/adminSecurity')}
|
||||||
|
rightSection={<LocalIcon icon="arrow-forward" width="0.9rem" height="0.9rem" />}
|
||||||
|
>
|
||||||
|
{t('usage.configureSettings', 'Configure Analytics Settings')}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="light"
|
||||||
|
size="xs"
|
||||||
|
onClick={() => navigate('/settings/adminSecurity#auditLogging')}
|
||||||
|
rightSection={<LocalIcon icon="arrow-forward" width="0.9rem" height="0.9rem" />}
|
||||||
|
>
|
||||||
|
{t('usage.viewAuditLogs', 'View Audit Logs')}
|
||||||
|
</Button>
|
||||||
|
</Group>
|
||||||
|
</Stack>
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Controls */}
|
{/* Controls */}
|
||||||
<Card padding="lg" radius="md" withBorder>
|
<Card padding="lg" radius="md" withBorder>
|
||||||
<Stack gap="md">
|
<Stack gap="md">
|
||||||
|
|||||||
+191
-98
@@ -1,65 +1,59 @@
|
|||||||
import React, { useState, useEffect } from 'react';
|
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 { useTranslation } from 'react-i18next';
|
||||||
import auditService, { AuditChartsData } from '@app/services/auditService';
|
import auditService, { AuditChartsData } from '@app/services/auditService';
|
||||||
|
|
||||||
interface SimpleBarChartProps {
|
// Event type color mapping
|
||||||
data: { label: string; value: number }[];
|
const EVENT_TYPE_COLORS: Record<string, string> = {
|
||||||
title: string;
|
USER_LOGIN: 'var(--mantine-color-green-6)',
|
||||||
color?: string;
|
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<SimpleBarChartProps> = ({ data, title, color = 'blue' }) => {
|
const getEventTypeColor = (type: string): string => {
|
||||||
const maxValue = Math.max(...data.map((d) => d.value), 1);
|
return EVENT_TYPE_COLORS[type] || 'var(--mantine-color-blue-6)';
|
||||||
|
|
||||||
return (
|
|
||||||
<Stack gap="sm">
|
|
||||||
<Text size="sm" fw={600}>
|
|
||||||
{title}
|
|
||||||
</Text>
|
|
||||||
<Stack gap="sm">
|
|
||||||
{data.map((item, index) => (
|
|
||||||
<Box key={index}>
|
|
||||||
<Group justify="space-between" mb={4}>
|
|
||||||
<Text size="xs" c="dimmed" maw={200} style={{ overflow: 'hidden', textOverflow: 'ellipsis' }}>
|
|
||||||
{item.label}
|
|
||||||
</Text>
|
|
||||||
<Text size="xs" fw={600}>
|
|
||||||
{item.value}
|
|
||||||
</Text>
|
|
||||||
</Group>
|
|
||||||
<Box
|
|
||||||
style={{
|
|
||||||
width: '100%',
|
|
||||||
height: '0.5rem',
|
|
||||||
backgroundColor: 'var(--mantine-color-gray-2)',
|
|
||||||
borderRadius: 'var(--mantine-radius-sm)',
|
|
||||||
overflow: 'hidden',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Box
|
|
||||||
style={{
|
|
||||||
width: `${(item.value / maxValue) * 100}%`,
|
|
||||||
height: '100%',
|
|
||||||
backgroundColor: `var(--mantine-color-${color}-6)`,
|
|
||||||
transition: 'width 0.3s ease',
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</Box>
|
|
||||||
</Box>
|
|
||||||
))}
|
|
||||||
</Stack>
|
|
||||||
</Stack>
|
|
||||||
);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
interface AuditChartsSectionProps {
|
interface AuditChartsSectionProps {
|
||||||
loginEnabled?: boolean;
|
loginEnabled?: boolean;
|
||||||
|
timePeriod?: 'day' | 'week' | 'month';
|
||||||
|
onTimePeriodChange?: (period: 'day' | 'week' | 'month') => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const AuditChartsSection: React.FC<AuditChartsSectionProps> = ({ loginEnabled = true }) => {
|
const AuditChartsSection: React.FC<AuditChartsSectionProps> = ({
|
||||||
|
loginEnabled = true,
|
||||||
|
timePeriod = 'week',
|
||||||
|
onTimePeriodChange,
|
||||||
|
}) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [timePeriod, setTimePeriod] = useState<'day' | 'week' | 'month'>('week');
|
|
||||||
const [chartsData, setChartsData] = useState<AuditChartsData | null>(null);
|
const [chartsData, setChartsData] = useState<AuditChartsData | null>(null);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
@@ -72,7 +66,7 @@ const AuditChartsSection: React.FC<AuditChartsSectionProps> = ({ loginEnabled =
|
|||||||
const data = await auditService.getChartsData(timePeriod);
|
const data = await auditService.getChartsData(timePeriod);
|
||||||
setChartsData(data);
|
setChartsData(data);
|
||||||
} catch (err) {
|
} 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 {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
@@ -81,7 +75,7 @@ const AuditChartsSection: React.FC<AuditChartsSectionProps> = ({ loginEnabled =
|
|||||||
if (loginEnabled) {
|
if (loginEnabled) {
|
||||||
fetchChartsData();
|
fetchChartsData();
|
||||||
} else {
|
} else {
|
||||||
// Provide example charts data when login is disabled
|
// Demo data when login disabled
|
||||||
setChartsData({
|
setChartsData({
|
||||||
eventsByType: {
|
eventsByType: {
|
||||||
labels: ['LOGIN', 'LOGOUT', 'SETTINGS_CHANGE', 'FILE_UPLOAD', 'FILE_DOWNLOAD'],
|
labels: ['LOGIN', 'LOGOUT', 'SETTINGS_CHANGE', 'FILE_UPLOAD', 'FILE_DOWNLOAD'],
|
||||||
@@ -122,62 +116,161 @@ const AuditChartsSection: React.FC<AuditChartsSectionProps> = ({ loginEnabled =
|
|||||||
return null;
|
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) => ({
|
const eventsByTypeData = chartsData.eventsByType.labels.map((label, index) => ({
|
||||||
label,
|
type: label,
|
||||||
value: chartsData.eventsByType.values[index],
|
value: chartsData.eventsByType.values[index],
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const eventsByUserData = chartsData.eventsByUser.labels.map((label, index) => ({
|
const eventsByUserData = chartsData.eventsByUser.labels.map((label, index) => ({
|
||||||
label,
|
user: label,
|
||||||
value: chartsData.eventsByUser.values[index],
|
value: chartsData.eventsByUser.values[index],
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const eventsOverTimeData = chartsData.eventsOverTime.labels.map((label, index) => ({
|
|
||||||
label,
|
|
||||||
value: chartsData.eventsOverTime.values[index],
|
|
||||||
}));
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card padding="lg" radius="md" withBorder>
|
<Stack gap="lg">
|
||||||
<Stack gap="lg">
|
{/* Header with time period selector */}
|
||||||
<Group justify="space-between" align="center">
|
<Group justify="space-between" align="center">
|
||||||
<Text size="lg" fw={600}>
|
<Text size="lg" fw={600}>
|
||||||
{t('audit.charts.title', 'Audit Dashboard')}
|
{t('audit.charts.title', 'Audit Dashboard')}
|
||||||
</Text>
|
</Text>
|
||||||
<SegmentedControl
|
<SegmentedControl
|
||||||
value={timePeriod}
|
value={timePeriod}
|
||||||
onChange={(value) => {
|
onChange={(value) => {
|
||||||
if (!loginEnabled) return;
|
onTimePeriodChange?.(value as 'day' | 'week' | 'month');
|
||||||
setTimePeriod(value as 'day' | 'week' | 'month');
|
}}
|
||||||
}}
|
disabled={!loginEnabled}
|
||||||
disabled={!loginEnabled}
|
data={[
|
||||||
data={[
|
{ label: t('audit.charts.day', 'Day'), value: 'day' },
|
||||||
{ label: t('audit.charts.day', 'Day'), value: 'day' },
|
{ label: t('audit.charts.week', 'Week'), value: 'week' },
|
||||||
{ label: t('audit.charts.week', 'Week'), value: 'week' },
|
{ label: t('audit.charts.month', 'Month'), value: 'month' },
|
||||||
{ label: t('audit.charts.month', 'Month'), value: 'month' },
|
]}
|
||||||
]}
|
/>
|
||||||
/>
|
</Group>
|
||||||
</Group>
|
|
||||||
|
|
||||||
<SimpleGrid cols={3} spacing="xl">
|
{/* Full-width Events Over Time Chart */}
|
||||||
<SimpleBarChart
|
<Card padding="lg" radius="md" withBorder>
|
||||||
data={eventsByTypeData}
|
<Stack gap="md">
|
||||||
title={t('audit.charts.byType', 'Events by Type')}
|
<Text size="md" fw={600}>
|
||||||
color="blue"
|
{t('audit.charts.overTime', 'Events Over Time')}
|
||||||
/>
|
</Text>
|
||||||
<SimpleBarChart
|
<Box style={{ width: '100%', height: 280 }}>
|
||||||
data={eventsByUserData}
|
{eventsOverTimeData.length > 0 ? (
|
||||||
title={t('audit.charts.byUser', 'Events by User')}
|
<ResponsiveContainer width="100%" height="100%">
|
||||||
color="green"
|
<AreaChart data={eventsOverTimeData}>
|
||||||
/>
|
<defs>
|
||||||
<SimpleBarChart
|
<linearGradient id="colorValue" x1="0" y1="0" x2="0" y2="1">
|
||||||
data={eventsOverTimeData}
|
<stop offset="5%" stopColor="var(--mantine-color-blue-6)" stopOpacity={0.3} />
|
||||||
title={t('audit.charts.overTime', 'Events Over Time')}
|
<stop offset="95%" stopColor="var(--mantine-color-blue-6)" stopOpacity={0} />
|
||||||
color="purple"
|
</linearGradient>
|
||||||
/>
|
</defs>
|
||||||
</SimpleGrid>
|
<CartesianGrid strokeDasharray="3 3" stroke="var(--mantine-color-gray-2)" />
|
||||||
</Stack>
|
<XAxis dataKey="name" stroke="var(--mantine-color-gray-6)" />
|
||||||
</Card>
|
<YAxis stroke="var(--mantine-color-gray-6)" />
|
||||||
|
<Tooltip
|
||||||
|
contentStyle={{
|
||||||
|
backgroundColor: 'var(--mantine-color-gray-8)',
|
||||||
|
border: 'none',
|
||||||
|
borderRadius: 'var(--mantine-radius-md)',
|
||||||
|
color: 'var(--mantine-color-gray-0)',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Area
|
||||||
|
type="monotone"
|
||||||
|
dataKey="value"
|
||||||
|
stroke="var(--mantine-color-blue-6)"
|
||||||
|
fillOpacity={1}
|
||||||
|
fill="url(#colorValue)"
|
||||||
|
/>
|
||||||
|
</AreaChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
) : (
|
||||||
|
<Group justify="center">
|
||||||
|
<Text c="dimmed">{t('audit.charts.noData', 'No data for this period')}</Text>
|
||||||
|
</Group>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
</Stack>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Two-column grid for remaining charts */}
|
||||||
|
<SimpleGrid cols={{ base: 1, md: 2 }} spacing="lg">
|
||||||
|
{/* Events by Type Chart */}
|
||||||
|
<Card padding="lg" radius="md" withBorder>
|
||||||
|
<Stack gap="md">
|
||||||
|
<Text size="md" fw={600}>
|
||||||
|
{t('audit.charts.byType', 'Events by Type')}
|
||||||
|
</Text>
|
||||||
|
<Box style={{ width: '100%', height: 280 }}>
|
||||||
|
{eventsByTypeData.length > 0 ? (
|
||||||
|
<ResponsiveContainer width="100%" height="100%">
|
||||||
|
<BarChart data={eventsByTypeData}>
|
||||||
|
<CartesianGrid strokeDasharray="3 3" stroke="var(--mantine-color-gray-2)" />
|
||||||
|
<XAxis dataKey="type" stroke="var(--mantine-color-gray-6)" angle={-45} textAnchor="end" height={80} />
|
||||||
|
<YAxis stroke="var(--mantine-color-gray-6)" />
|
||||||
|
<Tooltip
|
||||||
|
contentStyle={{
|
||||||
|
backgroundColor: 'var(--mantine-color-gray-8)',
|
||||||
|
border: 'none',
|
||||||
|
borderRadius: 'var(--mantine-radius-md)',
|
||||||
|
color: 'var(--mantine-color-gray-0)',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Bar dataKey="value" fill="var(--mantine-color-blue-6)">
|
||||||
|
{eventsByTypeData.map((entry, index) => (
|
||||||
|
<Cell key={`cell-${index}`} fill={getEventTypeColor(entry.type)} />
|
||||||
|
))}
|
||||||
|
</Bar>
|
||||||
|
</BarChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
) : (
|
||||||
|
<Group justify="center">
|
||||||
|
<Text c="dimmed">{t('audit.charts.noData', 'No data')}</Text>
|
||||||
|
</Group>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
</Stack>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Top Users Chart (Horizontal) */}
|
||||||
|
<Card padding="lg" radius="md" withBorder>
|
||||||
|
<Stack gap="md">
|
||||||
|
<Text size="md" fw={600}>
|
||||||
|
{t('audit.charts.byUser', 'Top Users')}
|
||||||
|
</Text>
|
||||||
|
<Box style={{ width: '100%', height: 280 }}>
|
||||||
|
{eventsByUserData.length > 0 ? (
|
||||||
|
<ResponsiveContainer width="100%" height="100%">
|
||||||
|
<BarChart data={eventsByUserData} layout="vertical">
|
||||||
|
<CartesianGrid strokeDasharray="3 3" stroke="var(--mantine-color-gray-2)" />
|
||||||
|
<XAxis type="number" stroke="var(--mantine-color-gray-6)" />
|
||||||
|
<YAxis type="category" dataKey="user" stroke="var(--mantine-color-gray-6)" width={100} />
|
||||||
|
<Tooltip
|
||||||
|
contentStyle={{
|
||||||
|
backgroundColor: 'var(--mantine-color-gray-8)',
|
||||||
|
border: 'none',
|
||||||
|
borderRadius: 'var(--mantine-radius-md)',
|
||||||
|
color: 'var(--mantine-color-gray-0)',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Bar dataKey="value" fill="var(--mantine-color-green-6)" />
|
||||||
|
</BarChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
) : (
|
||||||
|
<Group justify="center">
|
||||||
|
<Text c="dimmed">{t('audit.charts.noData', 'No data')}</Text>
|
||||||
|
</Group>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
</Stack>
|
||||||
|
</Card>
|
||||||
|
</SimpleGrid>
|
||||||
|
</Stack>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
+191
@@ -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<AuditClearDataSectionProps> = ({ 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<string | null>(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 (
|
||||||
|
<Stack gap="lg">
|
||||||
|
<Alert
|
||||||
|
color="green"
|
||||||
|
icon={<LocalIcon icon="check-circle" width="1.2rem" height="1.2rem" />}
|
||||||
|
title={t('audit.clearData.success', 'Success')}
|
||||||
|
onClose={() => setSuccess(false)}
|
||||||
|
closeButtonLabel="Close alert"
|
||||||
|
withCloseButton
|
||||||
|
>
|
||||||
|
{t('audit.clearData.successMessage', 'All audit data has been cleared successfully')}
|
||||||
|
</Alert>
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (showConfirmation) {
|
||||||
|
return (
|
||||||
|
<Stack gap="lg">
|
||||||
|
<Alert
|
||||||
|
color="orange"
|
||||||
|
icon={<LocalIcon icon="warning" width="1.2rem" height="1.2rem" />}
|
||||||
|
title={t('audit.clearData.confirmTitle', 'Please confirm you want to delete')}
|
||||||
|
>
|
||||||
|
<Text size="sm">
|
||||||
|
{t('audit.clearData.confirmMessage', 'This will permanently remove all audit logs. Enter the confirmation code below to proceed.')}
|
||||||
|
</Text>
|
||||||
|
</Alert>
|
||||||
|
|
||||||
|
<Card padding="lg" radius="md" withBorder style={{ borderColor: 'var(--mantine-color-red-4)' }}>
|
||||||
|
<Stack gap="md">
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
backgroundColor: 'var(--mantine-color-gray-0)',
|
||||||
|
padding: '1rem',
|
||||||
|
borderRadius: '0.375rem',
|
||||||
|
border: '1px solid var(--mantine-color-gray-2)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text size="xs" fw={600} c="dimmed" mb="xs">
|
||||||
|
{t('audit.clearData.confirmationCode', 'Confirmation Code')}
|
||||||
|
</Text>
|
||||||
|
<Code
|
||||||
|
style={{
|
||||||
|
fontSize: '1.5rem',
|
||||||
|
letterSpacing: '0.15em',
|
||||||
|
fontWeight: 600,
|
||||||
|
display: 'block',
|
||||||
|
textAlign: 'center',
|
||||||
|
padding: '0.75rem',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{generatedCode}
|
||||||
|
</Code>
|
||||||
|
<Text size="xs" c="dimmed" mt="xs">
|
||||||
|
{t('audit.clearData.enterCodeBelow', 'Enter the code exactly as shown above (case-sensitive)')}
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<PasswordInput
|
||||||
|
label={t('audit.clearData.enterCode', 'Confirmation Code')}
|
||||||
|
placeholder={t('audit.clearData.codePlaceholder', 'Type the code here')}
|
||||||
|
value={confirmationCode}
|
||||||
|
onChange={(e) => setConfirmationCode(e.currentTarget.value)}
|
||||||
|
disabled={!loginEnabled}
|
||||||
|
error={
|
||||||
|
confirmationCode && confirmationCode !== generatedCode
|
||||||
|
? t('audit.clearData.codeDoesNotMatch', 'Code does not match')
|
||||||
|
: false
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<Alert color="red" icon={<LocalIcon icon="error" width="1.2rem" height="1.2rem" />}>
|
||||||
|
{error}
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Group justify="space-between">
|
||||||
|
<Button variant="default" onClick={resetForm} disabled={clearing}>
|
||||||
|
{t('audit.clearData.cancel', 'Cancel')}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
color="red"
|
||||||
|
onClick={handleClearData}
|
||||||
|
loading={clearing}
|
||||||
|
disabled={!loginEnabled || !generatedCode || confirmationCode !== generatedCode}
|
||||||
|
>
|
||||||
|
{t('audit.clearData.deleteButton', 'Delete')}
|
||||||
|
</Button>
|
||||||
|
</Group>
|
||||||
|
</Stack>
|
||||||
|
</Card>
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Stack gap="lg">
|
||||||
|
<Alert
|
||||||
|
color="red"
|
||||||
|
icon={<LocalIcon icon="warning" width="1.2rem" height="1.2rem" />}
|
||||||
|
title={t('audit.clearData.warning1', 'This action cannot be undone')}
|
||||||
|
>
|
||||||
|
<Text size="sm">
|
||||||
|
{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.')}
|
||||||
|
</Text>
|
||||||
|
</Alert>
|
||||||
|
|
||||||
|
<Card padding="lg" radius="md" withBorder style={{ borderColor: 'var(--mantine-color-red-4)' }}>
|
||||||
|
<Stack gap="md">
|
||||||
|
<Group>
|
||||||
|
<Text fw={600}>
|
||||||
|
{t('audit.clearData.confirmationRequired', 'Delete All Audit Data')}
|
||||||
|
</Text>
|
||||||
|
<Badge color="red">
|
||||||
|
{t('audit.clearData.irreversible', 'IRREVERSIBLE')}
|
||||||
|
</Badge>
|
||||||
|
</Group>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
color="red"
|
||||||
|
onClick={handleInitiateDeletion}
|
||||||
|
disabled={!loginEnabled}
|
||||||
|
fullWidth
|
||||||
|
>
|
||||||
|
{t('audit.clearData.initiateDelete', 'Delete All Data')}
|
||||||
|
</Button>
|
||||||
|
</Stack>
|
||||||
|
</Card>
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default AuditClearDataSection;
|
||||||
+179
-41
@@ -11,18 +11,27 @@ import {
|
|||||||
Loader,
|
Loader,
|
||||||
Alert,
|
Alert,
|
||||||
Table,
|
Table,
|
||||||
|
Badge,
|
||||||
|
UnstyledButton,
|
||||||
} from '@mantine/core';
|
} from '@mantine/core';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import auditService, { AuditEvent } from '@app/services/auditService';
|
import auditService, { AuditEvent } from '@app/services/auditService';
|
||||||
import { Z_INDEX_OVER_CONFIG_MODAL } from '@app/styles/zIndex';
|
import { Z_INDEX_OVER_CONFIG_MODAL } from '@app/styles/zIndex';
|
||||||
import { useAuditFilters } from '@app/hooks/useAuditFilters';
|
import { useAuditFilters } from '@app/hooks/useAuditFilters';
|
||||||
import AuditFiltersForm from '@app/components/shared/config/configSections/audit/AuditFiltersForm';
|
import AuditFiltersForm from '@app/components/shared/config/configSections/audit/AuditFiltersForm';
|
||||||
|
import LocalIcon from '@app/components/shared/LocalIcon';
|
||||||
|
|
||||||
interface AuditEventsTableProps {
|
interface AuditEventsTableProps {
|
||||||
loginEnabled?: boolean;
|
loginEnabled?: boolean;
|
||||||
|
captureFileHash?: boolean;
|
||||||
|
capturePdfAuthor?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const AuditEventsTable: React.FC<AuditEventsTableProps> = ({ loginEnabled = true }) => {
|
const AuditEventsTable: React.FC<AuditEventsTableProps> = ({
|
||||||
|
loginEnabled = true,
|
||||||
|
captureFileHash = false,
|
||||||
|
capturePdfAuthor = false,
|
||||||
|
}) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [events, setEvents] = useState<AuditEvent[]>([]);
|
const [events, setEvents] = useState<AuditEvent[]>([]);
|
||||||
const [totalPages, setTotalPages] = useState(0);
|
const [totalPages, setTotalPages] = useState(0);
|
||||||
@@ -30,6 +39,11 @@ const AuditEventsTable: React.FC<AuditEventsTableProps> = ({ loginEnabled = true
|
|||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [selectedEvent, setSelectedEvent] = useState<AuditEvent | null>(null);
|
const [selectedEvent, setSelectedEvent] = useState<AuditEvent | null>(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
|
// Use shared filters hook
|
||||||
const { filters, eventTypes, users, handleFilterChange, handleClearFilters } = useAuditFilters({
|
const { filters, eventTypes, users, handleFilterChange, handleClearFilters } = useAuditFilters({
|
||||||
@@ -122,6 +136,69 @@ const AuditEventsTable: React.FC<AuditEventsTableProps> = ({ loginEnabled = true
|
|||||||
return new Date(dateString).toLocaleString();
|
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<string, string> = {
|
||||||
|
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 (
|
return (
|
||||||
<Card padding="lg" radius="md" withBorder>
|
<Card padding="lg" radius="md" withBorder>
|
||||||
<Stack gap="md">
|
<Stack gap="md">
|
||||||
@@ -150,6 +227,7 @@ const AuditEventsTable: React.FC<AuditEventsTableProps> = ({ loginEnabled = true
|
|||||||
</Alert>
|
</Alert>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
|
<div style={{ overflowX: 'auto', overflowY: 'hidden', marginBottom: '1rem' }}>
|
||||||
<Table
|
<Table
|
||||||
horizontalSpacing="md"
|
horizontalSpacing="md"
|
||||||
verticalSpacing="sm"
|
verticalSpacing="sm"
|
||||||
@@ -161,59 +239,118 @@ const AuditEventsTable: React.FC<AuditEventsTableProps> = ({ loginEnabled = true
|
|||||||
>
|
>
|
||||||
<Table.Thead>
|
<Table.Thead>
|
||||||
<Table.Tr style={{ backgroundColor: 'var(--mantine-color-gray-0)' }}>
|
<Table.Tr style={{ backgroundColor: 'var(--mantine-color-gray-0)' }}>
|
||||||
<Table.Th style={{ fontWeight: 600, color: 'var(--mantine-color-gray-7)' }} fz="sm">
|
<Table.Th style={{ fontWeight: 600, color: 'var(--mantine-color-gray-7)', padding: '0.5rem' }} fz="sm">
|
||||||
{t('audit.events.timestamp', 'Timestamp')}
|
<UnstyledButton onClick={() => toggleSort('timestamp')} style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', cursor: 'pointer', userSelect: 'none' }}>
|
||||||
|
{t('audit.events.timestamp', 'Timestamp')}
|
||||||
|
<LocalIcon icon={getSortIcon('timestamp')} width="0.9rem" height="0.9rem" />
|
||||||
|
</UnstyledButton>
|
||||||
|
</Table.Th>
|
||||||
|
<Table.Th style={{ fontWeight: 600, color: 'var(--mantine-color-gray-7)', padding: '0.5rem' }} fz="sm">
|
||||||
|
<UnstyledButton onClick={() => toggleSort('eventType')} style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', cursor: 'pointer', userSelect: 'none' }}>
|
||||||
|
{t('audit.events.type', 'Type')}
|
||||||
|
<LocalIcon icon={getSortIcon('eventType')} width="0.9rem" height="0.9rem" />
|
||||||
|
</UnstyledButton>
|
||||||
|
</Table.Th>
|
||||||
|
<Table.Th style={{ fontWeight: 600, color: 'var(--mantine-color-gray-7)', padding: '0.5rem' }} fz="sm">
|
||||||
|
<UnstyledButton onClick={() => toggleSort('username')} style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', cursor: 'pointer', userSelect: 'none' }}>
|
||||||
|
{t('audit.events.user', 'User')}
|
||||||
|
<LocalIcon icon={getSortIcon('username')} width="0.9rem" height="0.9rem" />
|
||||||
|
</UnstyledButton>
|
||||||
</Table.Th>
|
</Table.Th>
|
||||||
<Table.Th style={{ fontWeight: 600, color: 'var(--mantine-color-gray-7)' }} fz="sm">
|
<Table.Th style={{ fontWeight: 600, color: 'var(--mantine-color-gray-7)' }} fz="sm">
|
||||||
{t('audit.events.type', 'Type')}
|
{t('audit.events.documentName', 'Document Name')}
|
||||||
</Table.Th>
|
|
||||||
<Table.Th style={{ fontWeight: 600, color: 'var(--mantine-color-gray-7)' }} fz="sm">
|
|
||||||
{t('audit.events.user', 'User')}
|
|
||||||
</Table.Th>
|
|
||||||
<Table.Th style={{ fontWeight: 600, color: 'var(--mantine-color-gray-7)' }} fz="sm">
|
|
||||||
{t('audit.events.ipAddress', 'IP Address')}
|
|
||||||
</Table.Th>
|
</Table.Th>
|
||||||
|
{showAuthor && (
|
||||||
|
<Table.Th style={{ fontWeight: 600, color: 'var(--mantine-color-gray-7)' }} fz="sm">
|
||||||
|
{t('audit.events.author', 'Author')}
|
||||||
|
</Table.Th>
|
||||||
|
)}
|
||||||
|
{showFileHash && (
|
||||||
|
<Table.Th style={{ fontWeight: 600, color: 'var(--mantine-color-gray-7)' }} fz="sm">
|
||||||
|
{t('audit.events.fileHash', 'File Hash')}
|
||||||
|
</Table.Th>
|
||||||
|
)}
|
||||||
<Table.Th style={{ fontWeight: 600, color: 'var(--mantine-color-gray-7)' }} fz="sm" ta="center">
|
<Table.Th style={{ fontWeight: 600, color: 'var(--mantine-color-gray-7)' }} fz="sm" ta="center">
|
||||||
{t('audit.events.actions', 'Actions')}
|
{t('audit.events.actions', 'Actions')}
|
||||||
</Table.Th>
|
</Table.Th>
|
||||||
</Table.Tr>
|
</Table.Tr>
|
||||||
</Table.Thead>
|
</Table.Thead>
|
||||||
<Table.Tbody>
|
<Table.Tbody>
|
||||||
{events.length === 0 ? (
|
{sortedEvents.length === 0 ? (
|
||||||
<Table.Tr>
|
<Table.Tr>
|
||||||
<Table.Td colSpan={5}>
|
<Table.Td colSpan={totalColumns}>
|
||||||
<Text ta="center" c="dimmed" py="xl">
|
<Group justify="center" py="xl">
|
||||||
{t('audit.events.noEvents', 'No events found')}
|
<Stack align="center" gap={0}>
|
||||||
</Text>
|
<LocalIcon icon="search" width="2rem" height="2rem" style={{ opacity: 0.4 }} />
|
||||||
|
<Text ta="center" c="dimmed" size="sm">
|
||||||
|
{t('audit.events.noEvents', 'No events found')}
|
||||||
|
</Text>
|
||||||
|
</Stack>
|
||||||
|
</Group>
|
||||||
</Table.Td>
|
</Table.Td>
|
||||||
</Table.Tr>
|
</Table.Tr>
|
||||||
) : (
|
) : (
|
||||||
events.map((event) => (
|
sortedEvents.map((event) => {
|
||||||
<Table.Tr key={event.id}>
|
// Extract document name, author, hash from details.files if available
|
||||||
<Table.Td>
|
let documentName = '';
|
||||||
<Text size="sm">{formatDate(event.timestamp)}</Text>
|
let author = '';
|
||||||
</Table.Td>
|
let fileHash = '';
|
||||||
<Table.Td>
|
if (event.details && typeof event.details === 'object') {
|
||||||
<Text size="sm">{event.eventType}</Text>
|
const details = event.details as Record<string, any>;
|
||||||
</Table.Td>
|
const files = details.files;
|
||||||
<Table.Td>
|
if (Array.isArray(files) && files.length > 0) {
|
||||||
<Text size="sm">{event.username}</Text>
|
const firstFile = files[0] as Record<string, any>;
|
||||||
</Table.Td>
|
documentName = firstFile.name || '';
|
||||||
<Table.Td>
|
if (showAuthor || showFileHash) {
|
||||||
<Text size="sm">{event.ipAddress}</Text>
|
author = firstFile.pdfAuthor || '';
|
||||||
</Table.Td>
|
fileHash = firstFile.fileHash ? firstFile.fileHash.substring(0, 16) + '...' : '';
|
||||||
<Table.Td ta="center">
|
}
|
||||||
<Button
|
}
|
||||||
variant="subtle"
|
}
|
||||||
size="xs"
|
|
||||||
onClick={() => setSelectedEvent(event)}
|
return (
|
||||||
disabled={!loginEnabled}
|
<Table.Tr key={event.id}>
|
||||||
>
|
<Table.Td>
|
||||||
{t('audit.events.viewDetails', 'View Details')}
|
<Text size="sm">{formatDate(event.timestamp)}</Text>
|
||||||
</Button>
|
</Table.Td>
|
||||||
</Table.Td>
|
<Table.Td>
|
||||||
</Table.Tr>
|
<Badge variant="light" size="sm" color={getEventTypeColor(event.eventType)}>
|
||||||
))
|
{event.eventType}
|
||||||
|
</Badge>
|
||||||
|
</Table.Td>
|
||||||
|
<Table.Td>
|
||||||
|
<Text size="sm">{event.username}</Text>
|
||||||
|
</Table.Td>
|
||||||
|
<Table.Td>
|
||||||
|
<Text size="sm" title={documentName}>
|
||||||
|
{documentName || '—'}
|
||||||
|
</Text>
|
||||||
|
</Table.Td>
|
||||||
|
{showAuthor && (
|
||||||
|
<Table.Td>
|
||||||
|
<Text size="sm">{author}</Text>
|
||||||
|
</Table.Td>
|
||||||
|
)}
|
||||||
|
{showFileHash && (
|
||||||
|
<Table.Td>
|
||||||
|
<Text size="sm" title={fileHash} style={{ fontFamily: 'monospace', fontSize: '0.75rem' }}>
|
||||||
|
{fileHash}
|
||||||
|
</Text>
|
||||||
|
</Table.Td>
|
||||||
|
)}
|
||||||
|
<Table.Td ta="center">
|
||||||
|
<Button
|
||||||
|
variant="subtle"
|
||||||
|
size="xs"
|
||||||
|
onClick={() => setSelectedEvent(event)}
|
||||||
|
disabled={!loginEnabled}
|
||||||
|
>
|
||||||
|
{t('audit.events.viewDetails', 'View Details')}
|
||||||
|
</Button>
|
||||||
|
</Table.Td>
|
||||||
|
</Table.Tr>
|
||||||
|
);
|
||||||
|
})
|
||||||
)}
|
)}
|
||||||
</Table.Tbody>
|
</Table.Tbody>
|
||||||
</Table>
|
</Table>
|
||||||
@@ -228,6 +365,7 @@ const AuditEventsTable: React.FC<AuditEventsTableProps> = ({ loginEnabled = true
|
|||||||
/>
|
/>
|
||||||
</Group>
|
</Group>
|
||||||
)}
|
)}
|
||||||
|
</div>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</Stack>
|
</Stack>
|
||||||
|
|||||||
+93
-2
@@ -6,6 +6,7 @@ import {
|
|||||||
Stack,
|
Stack,
|
||||||
Button,
|
Button,
|
||||||
SegmentedControl,
|
SegmentedControl,
|
||||||
|
Checkbox,
|
||||||
} from '@mantine/core';
|
} from '@mantine/core';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import auditService from '@app/services/auditService';
|
import auditService from '@app/services/auditService';
|
||||||
@@ -15,12 +16,31 @@ import AuditFiltersForm from '@app/components/shared/config/configSections/audit
|
|||||||
|
|
||||||
interface AuditExportSectionProps {
|
interface AuditExportSectionProps {
|
||||||
loginEnabled?: boolean;
|
loginEnabled?: boolean;
|
||||||
|
captureFileHash?: boolean;
|
||||||
|
capturePdfAuthor?: boolean;
|
||||||
|
captureOperationResults?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const AuditExportSection: React.FC<AuditExportSectionProps> = ({ loginEnabled = true }) => {
|
const AuditExportSection: React.FC<AuditExportSectionProps> = ({
|
||||||
|
loginEnabled = true,
|
||||||
|
captureFileHash = false,
|
||||||
|
capturePdfAuthor = false,
|
||||||
|
captureOperationResults = false,
|
||||||
|
}) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [exportFormat, setExportFormat] = useState<'csv' | 'json'>('csv');
|
const [exportFormat, setExportFormat] = useState<'csv' | 'json'>('csv');
|
||||||
const [exporting, setExporting] = useState(false);
|
const [exporting, setExporting] = useState(false);
|
||||||
|
const [selectedFields, setSelectedFields] = useState<Record<string, boolean>>({
|
||||||
|
date: true,
|
||||||
|
username: true,
|
||||||
|
ipaddress: false,
|
||||||
|
tool: true,
|
||||||
|
documentName: true,
|
||||||
|
outcome: true,
|
||||||
|
author: capturePdfAuthor,
|
||||||
|
fileHash: captureFileHash,
|
||||||
|
operationResults: captureOperationResults,
|
||||||
|
});
|
||||||
|
|
||||||
// Use shared filters hook
|
// Use shared filters hook
|
||||||
const { filters, eventTypes, users, handleFilterChange, handleClearFilters } = useAuditFilters({}, loginEnabled);
|
const { filters, eventTypes, users, handleFilterChange, handleClearFilters } = useAuditFilters({}, loginEnabled);
|
||||||
@@ -31,7 +51,9 @@ const AuditExportSection: React.FC<AuditExportSectionProps> = ({ loginEnabled =
|
|||||||
try {
|
try {
|
||||||
setExporting(true);
|
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
|
// Create download link
|
||||||
const url = window.URL.createObjectURL(blob);
|
const url = window.URL.createObjectURL(blob);
|
||||||
@@ -83,6 +105,75 @@ const AuditExportSection: React.FC<AuditExportSectionProps> = ({ loginEnabled =
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Field Selection */}
|
||||||
|
<div>
|
||||||
|
<Text size="sm" fw={600} mb="xs">
|
||||||
|
{t('audit.export.selectFields', 'Select Fields to Include')}
|
||||||
|
</Text>
|
||||||
|
<Stack gap="xs">
|
||||||
|
<Checkbox
|
||||||
|
label={t('audit.export.fieldDate', 'Date')}
|
||||||
|
checked={selectedFields.date}
|
||||||
|
onChange={(e) => setSelectedFields({ ...selectedFields, date: e.currentTarget.checked })}
|
||||||
|
disabled={!loginEnabled}
|
||||||
|
/>
|
||||||
|
<Checkbox
|
||||||
|
label={t('audit.export.fieldUsername', 'Username')}
|
||||||
|
checked={selectedFields.username}
|
||||||
|
onChange={(e) => setSelectedFields({ ...selectedFields, username: e.currentTarget.checked })}
|
||||||
|
disabled={!loginEnabled}
|
||||||
|
/>
|
||||||
|
<Checkbox
|
||||||
|
label={t('audit.export.fieldIpAddress', 'IP Address')}
|
||||||
|
checked={selectedFields.ipaddress}
|
||||||
|
onChange={(e) => setSelectedFields({ ...selectedFields, ipaddress: e.currentTarget.checked })}
|
||||||
|
disabled={!loginEnabled}
|
||||||
|
/>
|
||||||
|
<Checkbox
|
||||||
|
label={t('audit.export.fieldTool', 'Tool')}
|
||||||
|
checked={selectedFields.tool}
|
||||||
|
onChange={(e) => setSelectedFields({ ...selectedFields, tool: e.currentTarget.checked })}
|
||||||
|
disabled={!loginEnabled}
|
||||||
|
/>
|
||||||
|
<Checkbox
|
||||||
|
label={t('audit.export.fieldDocumentName', 'Document Name')}
|
||||||
|
checked={selectedFields.documentName}
|
||||||
|
onChange={(e) => setSelectedFields({ ...selectedFields, documentName: e.currentTarget.checked })}
|
||||||
|
disabled={!loginEnabled}
|
||||||
|
/>
|
||||||
|
<Checkbox
|
||||||
|
label={t('audit.export.fieldOutcome', 'Outcome (Success/Failure)')}
|
||||||
|
checked={selectedFields.outcome}
|
||||||
|
onChange={(e) => setSelectedFields({ ...selectedFields, outcome: e.currentTarget.checked })}
|
||||||
|
disabled={!loginEnabled}
|
||||||
|
/>
|
||||||
|
{capturePdfAuthor && (
|
||||||
|
<Checkbox
|
||||||
|
label={t('audit.export.fieldAuthor', 'Author (from PDF)')}
|
||||||
|
checked={selectedFields.author}
|
||||||
|
onChange={(e) => setSelectedFields({ ...selectedFields, author: e.currentTarget.checked })}
|
||||||
|
disabled={!loginEnabled}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{captureFileHash && (
|
||||||
|
<Checkbox
|
||||||
|
label={t('audit.export.fieldFileHash', 'File Hash (SHA-256)')}
|
||||||
|
checked={selectedFields.fileHash}
|
||||||
|
onChange={(e) => setSelectedFields({ ...selectedFields, fileHash: e.currentTarget.checked })}
|
||||||
|
disabled={!loginEnabled}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{captureOperationResults && (
|
||||||
|
<Checkbox
|
||||||
|
label={t('audit.export.fieldOperationResults', 'Operation Results')}
|
||||||
|
checked={selectedFields.operationResults}
|
||||||
|
onChange={(e) => setSelectedFields({ ...selectedFields, operationResults: e.currentTarget.checked })}
|
||||||
|
disabled={!loginEnabled}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Stack>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Filters */}
|
{/* Filters */}
|
||||||
<div>
|
<div>
|
||||||
<Text size="sm" fw={600} mb="xs">
|
<Text size="sm" fw={600} mb="xs">
|
||||||
|
|||||||
+146
-49
@@ -1,10 +1,44 @@
|
|||||||
import React from 'react';
|
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 { DateInput } from '@mantine/dates';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { AuditFilters } from '@app/services/auditService';
|
import { AuditFilters } from '@app/services/auditService';
|
||||||
import { Z_INDEX_OVER_CONFIG_MODAL } from '@app/styles/zIndex';
|
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 {
|
interface AuditFiltersFormProps {
|
||||||
filters: AuditFilters;
|
filters: AuditFilters;
|
||||||
eventTypes: string[];
|
eventTypes: string[];
|
||||||
@@ -27,55 +61,118 @@ const AuditFiltersForm: React.FC<AuditFiltersFormProps> = ({
|
|||||||
}) => {
|
}) => {
|
||||||
const { t } = useTranslation();
|
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 (
|
return (
|
||||||
<Group>
|
<Stack gap="md">
|
||||||
<Select
|
{/* Quick Preset Buttons */}
|
||||||
placeholder={t('audit.events.filterByType', 'Filter by type')}
|
<div>
|
||||||
data={eventTypes.map((type) => ({ value: type, label: type }))}
|
<Text size="xs" fw={600} mb="xs" c="dimmed">
|
||||||
value={filters.eventType}
|
{t('audit.filters.quickPresets', 'Quick filters')}
|
||||||
onChange={(value) => onFilterChange('eventType', value || undefined)}
|
</Text>
|
||||||
clearable
|
<Group gap="xs">
|
||||||
disabled={disabled}
|
<Button
|
||||||
style={{ flex: 1, minWidth: 200 }}
|
variant={isPresetActive('today') ? 'filled' : 'light'}
|
||||||
comboboxProps={{ withinPortal: true, zIndex: Z_INDEX_OVER_CONFIG_MODAL }}
|
size="xs"
|
||||||
/>
|
onClick={() => handleQuickPreset('today')}
|
||||||
<Select
|
disabled={disabled}
|
||||||
placeholder={t('audit.events.filterByUser', 'Filter by user')}
|
>
|
||||||
data={users.map((user) => ({ value: user, label: user }))}
|
{t('audit.filters.today', 'Today')}
|
||||||
value={filters.username}
|
</Button>
|
||||||
onChange={(value) => onFilterChange('username', value || undefined)}
|
<Button
|
||||||
clearable
|
variant={isPresetActive('last7') ? 'filled' : 'light'}
|
||||||
searchable
|
size="xs"
|
||||||
disabled={disabled}
|
onClick={() => handleQuickPreset('last7')}
|
||||||
style={{ flex: 1, minWidth: 200 }}
|
disabled={disabled}
|
||||||
comboboxProps={{ withinPortal: true, zIndex: Z_INDEX_OVER_CONFIG_MODAL }}
|
>
|
||||||
/>
|
{t('audit.filters.last7Days', 'Last 7 days')}
|
||||||
<DateInput
|
</Button>
|
||||||
placeholder={t('audit.events.startDate', 'Start date')}
|
<Button
|
||||||
value={filters.startDate ? new Date(filters.startDate) : null}
|
variant={isPresetActive('last30') ? 'filled' : 'light'}
|
||||||
onChange={(value: string | null) =>
|
size="xs"
|
||||||
onFilterChange('startDate', value ?? undefined)
|
onClick={() => handleQuickPreset('last30')}
|
||||||
}
|
disabled={disabled}
|
||||||
clearable
|
>
|
||||||
disabled={disabled}
|
{t('audit.filters.last30Days', 'Last 30 days')}
|
||||||
style={{ flex: 1, minWidth: 150 }}
|
</Button>
|
||||||
popoverProps={{ withinPortal: true, zIndex: Z_INDEX_OVER_CONFIG_MODAL }}
|
<Button
|
||||||
/>
|
variant={isPresetActive('thisMonth') ? 'filled' : 'light'}
|
||||||
<DateInput
|
size="xs"
|
||||||
placeholder={t('audit.events.endDate', 'End date')}
|
onClick={() => handleQuickPreset('thisMonth')}
|
||||||
value={filters.endDate ? new Date(filters.endDate) : null}
|
disabled={disabled}
|
||||||
onChange={(value: string | null) =>
|
>
|
||||||
onFilterChange('endDate', value ?? undefined)
|
{t('audit.filters.thisMonth', 'This month')}
|
||||||
}
|
</Button>
|
||||||
clearable
|
</Group>
|
||||||
disabled={disabled}
|
</div>
|
||||||
style={{ flex: 1, minWidth: 150 }}
|
|
||||||
popoverProps={{ withinPortal: true, zIndex: Z_INDEX_OVER_CONFIG_MODAL }}
|
{/* Filter Inputs */}
|
||||||
/>
|
<SimpleGrid cols={{ base: 1, sm: 2, md: 4 }} spacing="sm">
|
||||||
<Button variant="outline" onClick={onClearFilters} disabled={disabled}>
|
<MultiSelect
|
||||||
{t('audit.events.clearFilters', 'Clear')}
|
placeholder={t('audit.events.filterByType', 'Filter by type')}
|
||||||
</Button>
|
data={eventTypes.map((type) => ({ value: type, label: type }))}
|
||||||
</Group>
|
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 }}
|
||||||
|
/>
|
||||||
|
<MultiSelect
|
||||||
|
placeholder={t('audit.events.filterByUser', 'Filter by user')}
|
||||||
|
data={users.map((user) => ({ 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 }}
|
||||||
|
/>
|
||||||
|
<DateInput
|
||||||
|
placeholder={t('audit.events.startDate', 'Start date')}
|
||||||
|
value={filters.startDate ? new Date(filters.startDate) : null}
|
||||||
|
onChange={(value: any) => {
|
||||||
|
onFilterChange('startDate', value ? formatDateToYMD(value as Date) : undefined);
|
||||||
|
}}
|
||||||
|
clearable
|
||||||
|
disabled={disabled}
|
||||||
|
popoverProps={{ withinPortal: true, zIndex: Z_INDEX_OVER_CONFIG_MODAL }}
|
||||||
|
/>
|
||||||
|
<DateInput
|
||||||
|
placeholder={t('audit.events.endDate', 'End date')}
|
||||||
|
value={filters.endDate ? new Date(filters.endDate) : null}
|
||||||
|
onChange={(value: any) => {
|
||||||
|
onFilterChange('endDate', value ? formatDateToYMD(value as Date) : undefined);
|
||||||
|
}}
|
||||||
|
clearable
|
||||||
|
disabled={disabled}
|
||||||
|
popoverProps={{ withinPortal: true, zIndex: Z_INDEX_OVER_CONFIG_MODAL }}
|
||||||
|
/>
|
||||||
|
</SimpleGrid>
|
||||||
|
|
||||||
|
{/* Clear Button */}
|
||||||
|
<Group justify="flex-end">
|
||||||
|
<Button variant="outline" size="sm" onClick={onClearFilters} disabled={disabled}>
|
||||||
|
{t('audit.events.clearFilters', 'Clear')}
|
||||||
|
</Button>
|
||||||
|
</Group>
|
||||||
|
</Stack>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
+232
@@ -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<AuditStatsCardsProps> = ({ loginEnabled = true, timePeriod = 'week' }) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const [stats, setStats] = useState<AuditStats | null>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(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 (
|
||||||
|
<Card padding="lg" radius="md" withBorder>
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', padding: '2rem 0' }}>
|
||||||
|
<Loader size="lg" />
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<Alert color="red" title={t('audit.stats.error', 'Error loading statistics')}>
|
||||||
|
{error}
|
||||||
|
</Alert>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<SimpleGrid cols={{ base: 1, sm: 2, md: 4 }} spacing="lg">
|
||||||
|
{/* Total Events Card */}
|
||||||
|
<Card padding="lg" radius="md" withBorder>
|
||||||
|
<Stack gap="md">
|
||||||
|
<Group justify="space-between">
|
||||||
|
<Text size="sm" c="dimmed">
|
||||||
|
{t('audit.stats.totalEvents', 'Total Events')}
|
||||||
|
</Text>
|
||||||
|
<LocalIcon icon="analytics" width="1.2rem" height="1.2rem" />
|
||||||
|
</Group>
|
||||||
|
<Text size="xl" fw={700}>
|
||||||
|
{stats.totalEvents.toLocaleString()}
|
||||||
|
</Text>
|
||||||
|
{trendPercent !== 0 && (
|
||||||
|
<Badge
|
||||||
|
color={getTrendColor(trendPercent)}
|
||||||
|
variant="light"
|
||||||
|
size="sm"
|
||||||
|
leftSection={
|
||||||
|
<LocalIcon
|
||||||
|
icon={getTrendIcon(trendPercent)}
|
||||||
|
width="0.8rem"
|
||||||
|
height="0.8rem"
|
||||||
|
style={{ marginRight: '0.25rem' }}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{Math.abs(trendPercent).toFixed(1)}% {t('audit.stats.vsLastPeriod', 'vs last period')}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</Stack>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Success Rate Card */}
|
||||||
|
<Card padding="lg" radius="md" withBorder>
|
||||||
|
<Stack gap="md">
|
||||||
|
<Group justify="space-between">
|
||||||
|
<Text size="sm" c="dimmed">
|
||||||
|
{t('audit.stats.successRate', 'Success Rate')}
|
||||||
|
</Text>
|
||||||
|
<LocalIcon icon="check-circle-rounded" width="1.2rem" height="1.2rem" />
|
||||||
|
</Group>
|
||||||
|
<Text size="xl" fw={700}>
|
||||||
|
{stats.successRate.toFixed(1)}%
|
||||||
|
</Text>
|
||||||
|
<Group gap="xs">
|
||||||
|
<Badge color={getSuccessRateColor(stats.successRate)} variant="light" size="sm">
|
||||||
|
{stats.successRate >= 95
|
||||||
|
? t('audit.stats.excellent', 'Excellent')
|
||||||
|
: stats.successRate >= 80
|
||||||
|
? t('audit.stats.good', 'Good')
|
||||||
|
: t('audit.stats.attention', 'Attention needed')}
|
||||||
|
</Badge>
|
||||||
|
{successTrend !== 0 && (
|
||||||
|
<Badge color={getTrendColor(successTrend)} variant="light" size="xs">
|
||||||
|
{successTrend > 0 ? '+' : ''}{successTrend.toFixed(1)}%
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</Group>
|
||||||
|
</Stack>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Active Users Card */}
|
||||||
|
<Card padding="lg" radius="md" withBorder>
|
||||||
|
<Stack gap="md">
|
||||||
|
<Group justify="space-between">
|
||||||
|
<Text size="sm" c="dimmed">
|
||||||
|
{t('audit.stats.activeUsers', 'Active Users')}
|
||||||
|
</Text>
|
||||||
|
<LocalIcon icon="group" width="1.2rem" height="1.2rem" />
|
||||||
|
</Group>
|
||||||
|
<Text size="xl" fw={700}>
|
||||||
|
{stats.uniqueUsers}
|
||||||
|
</Text>
|
||||||
|
{userTrend !== 0 && (
|
||||||
|
<Badge
|
||||||
|
color={getTrendColor(userTrend)}
|
||||||
|
variant="light"
|
||||||
|
size="sm"
|
||||||
|
leftSection={
|
||||||
|
<LocalIcon
|
||||||
|
icon={getTrendIcon(userTrend)}
|
||||||
|
width="0.8rem"
|
||||||
|
height="0.8rem"
|
||||||
|
style={{ marginRight: '0.25rem' }}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{Math.abs(userTrend).toFixed(1)}%
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</Stack>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Avg Latency Card */}
|
||||||
|
<Card padding="lg" radius="md" withBorder>
|
||||||
|
<Stack gap="md">
|
||||||
|
<Group justify="space-between">
|
||||||
|
<Text size="sm" c="dimmed">
|
||||||
|
{t('audit.stats.avgLatency', 'Avg Latency')}
|
||||||
|
</Text>
|
||||||
|
<LocalIcon icon="speed" width="1.2rem" height="1.2rem" />
|
||||||
|
</Group>
|
||||||
|
<Text size="xl" fw={700}>
|
||||||
|
{stats.avgLatencyMs > 0 ? `${stats.avgLatencyMs.toFixed(0)}ms` : t('audit.stats.noData', 'N/A')}
|
||||||
|
</Text>
|
||||||
|
{latencyTrend !== 0 && stats.avgLatencyMs > 0 && (
|
||||||
|
<Badge
|
||||||
|
color={getTrendColor(latencyTrend, true)}
|
||||||
|
variant="light"
|
||||||
|
size="sm"
|
||||||
|
leftSection={
|
||||||
|
<LocalIcon
|
||||||
|
icon={getTrendIcon(latencyTrend, true)}
|
||||||
|
width="0.8rem"
|
||||||
|
height="0.8rem"
|
||||||
|
style={{ marginRight: '0.25rem' }}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{Math.abs(latencyTrend).toFixed(1)}%
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</Stack>
|
||||||
|
</Card>
|
||||||
|
</SimpleGrid>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default AuditStatsCards;
|
||||||
+39
-1
@@ -1,5 +1,5 @@
|
|||||||
import React from 'react';
|
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 { useTranslation } from 'react-i18next';
|
||||||
import { AuditSystemStatus as AuditStatus } from '@app/services/auditService';
|
import { AuditSystemStatus as AuditStatus } from '@app/services/auditService';
|
||||||
|
|
||||||
@@ -56,6 +56,44 @@ const AuditSystemStatus: React.FC<AuditSystemStatusProps> = ({ status }) => {
|
|||||||
</Text>
|
</Text>
|
||||||
</div>
|
</div>
|
||||||
</Group>
|
</Group>
|
||||||
|
|
||||||
|
<Divider />
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<Text size="sm" fw={600} mb="xs">
|
||||||
|
{t('audit.systemStatus.capturedFields', 'Captured Fields')}
|
||||||
|
</Text>
|
||||||
|
<Group gap="xs">
|
||||||
|
<Badge color="green" variant="light" size="sm">
|
||||||
|
{t('audit.systemStatus.username', 'Username')}
|
||||||
|
</Badge>
|
||||||
|
<Badge color="green" variant="light" size="sm">
|
||||||
|
{t('audit.systemStatus.documentName', 'Document Name')}
|
||||||
|
</Badge>
|
||||||
|
<Badge color="green" variant="light" size="sm">
|
||||||
|
{t('audit.systemStatus.tool', 'Tool')}
|
||||||
|
</Badge>
|
||||||
|
<Badge color="green" variant="light" size="sm">
|
||||||
|
{t('audit.systemStatus.date', 'Date')}
|
||||||
|
</Badge>
|
||||||
|
<Badge color={status.capturePdfAuthor ? 'green' : 'gray'} variant="light" size="sm">
|
||||||
|
{t('audit.systemStatus.pdfAuthor', 'PDF Author')}
|
||||||
|
{!status.capturePdfAuthor && (
|
||||||
|
<span style={{ marginLeft: '0.5rem', fontSize: '0.75rem', opacity: 0.7 }}>
|
||||||
|
({t('audit.systemStatus.captureBySettings', 'Enable in settings')})
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</Badge>
|
||||||
|
<Badge color={status.captureFileHash ? 'green' : 'gray'} variant="light" size="sm">
|
||||||
|
{t('audit.systemStatus.fileHash', 'File Hash')}
|
||||||
|
{!status.captureFileHash && (
|
||||||
|
<span style={{ marginLeft: '0.5rem', fontSize: '0.75rem', opacity: 0.7 }}>
|
||||||
|
({t('audit.systemStatus.captureBySettings', 'Enable in settings')})
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</Badge>
|
||||||
|
</Group>
|
||||||
|
</div>
|
||||||
</Stack>
|
</Stack>
|
||||||
</Card>
|
</Card>
|
||||||
);
|
);
|
||||||
|
|||||||
Reference in New Issue
Block a user