diff --git a/common/src/main/java/stirling/software/common/model/ApplicationProperties.java b/common/src/main/java/stirling/software/common/model/ApplicationProperties.java index 6949b2a21..27cc9b3ca 100644 --- a/common/src/main/java/stirling/software/common/model/ApplicationProperties.java +++ b/common/src/main/java/stirling/software/common/model/ApplicationProperties.java @@ -439,6 +439,7 @@ public class ApplicationProperties { @Data public static class ProFeatures { private boolean ssoAutoLogin; + private boolean database; private CustomMetadata customMetadata = new CustomMetadata(); private GoogleDrive googleDrive = new GoogleDrive(); @@ -484,7 +485,15 @@ public class ApplicationProperties { @Data public static class EnterpriseFeatures { private PersistentMetrics persistentMetrics = new PersistentMetrics(); - + private Audit audit = new Audit(); + + @Data + public static class Audit { + private boolean enabled = true; + private int level = 2; // 0=OFF, 1=BASIC, 2=STANDARD, 3=VERBOSE + private int retentionDays = 90; + } + @Data public static class PersistentMetrics { private boolean enabled; diff --git a/common/src/main/java/stirling/software/common/util/RequestUriUtils.java b/common/src/main/java/stirling/software/common/util/RequestUriUtils.java index 4c14901b3..654c78fe9 100644 --- a/common/src/main/java/stirling/software/common/util/RequestUriUtils.java +++ b/common/src/main/java/stirling/software/common/util/RequestUriUtils.java @@ -19,6 +19,7 @@ public class RequestUriUtils { || requestURI.endsWith(".svg") || requestURI.endsWith(".png") || requestURI.endsWith(".ico") + || requestURI.endsWith(".txt") || requestURI.endsWith(".webmanifest") || requestURI.startsWith(contextPath + "/api/v1/info/status"); } @@ -35,6 +36,7 @@ public class RequestUriUtils { || requestURI.endsWith(".png") || requestURI.endsWith(".ico") || requestURI.endsWith(".css") + || requestURI.endsWith(".txt") || requestURI.endsWith(".map") || requestURI.endsWith(".svg") || requestURI.endsWith("popularity.txt") diff --git a/proprietary/src/main/java/stirling/software/proprietary/audit/AuditAspect.java b/proprietary/src/main/java/stirling/software/proprietary/audit/AuditAspect.java new file mode 100644 index 000000000..519c901fd --- /dev/null +++ b/proprietary/src/main/java/stirling/software/proprietary/audit/AuditAspect.java @@ -0,0 +1,131 @@ +package stirling.software.proprietary.audit; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.apache.commons.lang3.StringUtils; +import org.aspectj.lang.ProceedingJoinPoint; +import org.aspectj.lang.annotation.Around; +import org.aspectj.lang.annotation.Aspect; +import org.aspectj.lang.reflect.MethodSignature; +import org.springframework.stereotype.Component; +import org.springframework.web.context.request.RequestContextHolder; +import org.springframework.web.context.request.ServletRequestAttributes; +import stirling.software.proprietary.config.AuditConfigurationProperties; +import stirling.software.proprietary.service.AuditService; + +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import java.lang.reflect.Method; +import java.util.HashMap; +import java.util.Map; + +/** + * Aspect for processing {@link Audited} annotations. + */ +@Aspect +@Component +@Slf4j +@RequiredArgsConstructor +public class AuditAspect { + + private final AuditService auditService; + private final AuditConfigurationProperties auditConfig; + + @Around("@annotation(stirling.software.proprietary.audit.Audited)") + public Object auditMethod(ProceedingJoinPoint joinPoint) throws Throwable { + MethodSignature signature = (MethodSignature) joinPoint.getSignature(); + Method method = signature.getMethod(); + Audited auditedAnnotation = method.getAnnotation(Audited.class); + + // Fast path: use unified check to determine if we should audit + // This avoids all data collection if auditing is disabled + if (!AuditUtils.shouldAudit(method, auditConfig)) { + return joinPoint.proceed(); + } + + // Only create the map once we know we'll use it + Map auditData = AuditUtils.createBaseAuditData(joinPoint, auditedAnnotation.level()); + + // Add HTTP information if we're in a web context + ServletRequestAttributes attrs = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes(); + if (attrs != null) { + HttpServletRequest req = attrs.getRequest(); + String path = req.getRequestURI(); + String httpMethod = req.getMethod(); + AuditUtils.addHttpData(auditData, httpMethod, path, auditedAnnotation.level()); + AuditUtils.addFileData(auditData, joinPoint, auditedAnnotation.level()); + } + + // Add arguments if requested and if at VERBOSE level, or if specifically requested + boolean includeArgs = auditedAnnotation.includeArgs() && + (auditedAnnotation.level() == AuditLevel.VERBOSE || + auditConfig.getAuditLevel() == AuditLevel.VERBOSE); + + if (includeArgs) { + AuditUtils.addMethodArguments(auditData, joinPoint, AuditLevel.VERBOSE); + } + + // Record start time for latency calculation + long startTime = System.currentTimeMillis(); + Object result; + try { + // Execute the method + result = joinPoint.proceed(); + + // Add success status + auditData.put("status", "success"); + + // Add result if requested and if at VERBOSE level + boolean includeResult = auditedAnnotation.includeResult() && + (auditedAnnotation.level() == AuditLevel.VERBOSE || + auditConfig.getAuditLevel() == AuditLevel.VERBOSE); + + if (includeResult && result != null) { + // Use safe string conversion with size limiting + auditData.put("result", AuditUtils.safeToString(result, 1000)); + } + + return result; + } catch (Throwable ex) { + // Always add failure information regardless of level + auditData.put("status", "failure"); + auditData.put("errorType", ex.getClass().getName()); + auditData.put("errorMessage", ex.getMessage()); + + // Re-throw the exception + throw ex; + } finally { + // Add timing information - use isHttpRequest=false to ensure we get timing for non-HTTP methods + HttpServletResponse resp = attrs != null ? attrs.getResponse() : null; + boolean isHttpRequest = attrs != null; + AuditUtils.addTimingData(auditData, startTime, resp, auditedAnnotation.level(), isHttpRequest); + + // Resolve the event type based on annotation and context + String httpMethod = null; + String path = null; + if (attrs != null) { + HttpServletRequest req = attrs.getRequest(); + httpMethod = req.getMethod(); + path = req.getRequestURI(); + } + + AuditEventType eventType = AuditUtils.resolveEventType( + method, + joinPoint.getTarget().getClass(), + path, + httpMethod, + auditedAnnotation + ); + + // Check if we should use string type instead + String typeString = auditedAnnotation.typeString(); + if (eventType == AuditEventType.HTTP_REQUEST && StringUtils.isNotEmpty(typeString)) { + // Use the string type (for backward compatibility) + auditService.audit(typeString, auditData, auditedAnnotation.level()); + } else { + // Use the enum type (preferred) + auditService.audit(eventType, auditData, auditedAnnotation.level()); + } + } + } +} \ No newline at end of file diff --git a/proprietary/src/main/java/stirling/software/proprietary/audit/AuditEventType.java b/proprietary/src/main/java/stirling/software/proprietary/audit/AuditEventType.java new file mode 100644 index 000000000..421f41ce5 --- /dev/null +++ b/proprietary/src/main/java/stirling/software/proprietary/audit/AuditEventType.java @@ -0,0 +1,62 @@ +package stirling.software.proprietary.audit; + +/** + * Standardized audit event types for the application. + */ +public enum AuditEventType { + // Authentication events - BASIC level + USER_LOGIN("User login"), + USER_LOGOUT("User logout"), + USER_FAILED_LOGIN("Failed login attempt"), + + // User/admin events - BASIC level + USER_PROFILE_UPDATE("User or profile operation"), + + // System configuration events - STANDARD level + SETTINGS_CHANGED("System or admin settings operation"), + + // File operations - STANDARD level + FILE_OPERATION("File operation"), + + // PDF operations - STANDARD level + PDF_PROCESS("PDF processing operation"), + + // HTTP requests - STANDARD level + HTTP_REQUEST("HTTP request"); + + private final String description; + + AuditEventType(String description) { + this.description = description; + } + + public String getDescription() { + return description; + } + + /** + * Get the enum value from a string representation. + * Useful for backward compatibility with string-based event types. + * + * @param type The string representation of the event type + * @return The corresponding enum value or null if not found + */ + public static AuditEventType fromString(String type) { + if (type == null) { + return null; + } + + try { + return AuditEventType.valueOf(type); + } catch (IllegalArgumentException e) { + // If the exact enum name doesn't match, try finding a similar one + for (AuditEventType eventType : values()) { + if (eventType.name().equalsIgnoreCase(type) || + eventType.getDescription().equalsIgnoreCase(type)) { + return eventType; + } + } + return null; + } + } +} \ No newline at end of file diff --git a/proprietary/src/main/java/stirling/software/proprietary/audit/AuditLevel.java b/proprietary/src/main/java/stirling/software/proprietary/audit/AuditLevel.java new file mode 100644 index 000000000..79ca32922 --- /dev/null +++ b/proprietary/src/main/java/stirling/software/proprietary/audit/AuditLevel.java @@ -0,0 +1,80 @@ +package stirling.software.proprietary.audit; + +/** + * Defines the different levels of audit logging available in the application. + */ +public enum AuditLevel { + /** + * OFF - No audit logging (level 0) + * Disables all audit logging except for critical security events + */ + OFF(0), + + /** + * BASIC - Minimal audit logging (level 1) + * Includes: + * - Authentication events (login, logout, failed logins) + * - Password changes + * - User/role changes + * - System configuration changes + */ + BASIC(1), + + /** + * STANDARD - Standard audit logging (level 2) + * Includes everything in BASIC plus: + * - All HTTP requests (basic info: URL, method, status) + * - File operations (upload, download, process) + * - PDF operations (view, edit, etc.) + * - User operations + */ + STANDARD(2), + + /** + * VERBOSE - Detailed audit logging (level 3) + * Includes everything in STANDARD plus: + * - Request headers and parameters + * - Method parameters + * - Operation results + * - Detailed timing information + */ + VERBOSE(3); + + private final int level; + + AuditLevel(int level) { + this.level = level; + } + + public int getLevel() { + return level; + } + + /** + * Checks if this audit level includes the specified level + * @param otherLevel The level to check against + * @return true if this level is equal to or greater than the specified level + */ + public boolean includes(AuditLevel otherLevel) { + return this.level >= otherLevel.level; + } + + /** + * Get an AuditLevel from an integer value + * @param level The integer level (0-3) + * @return The corresponding AuditLevel + */ + public static AuditLevel fromInt(int level) { + // Ensure level is within valid bounds + int boundedLevel = Math.min(Math.max(level, 0), 3); + + for (AuditLevel auditLevel : values()) { + if (auditLevel.level == boundedLevel) { + return auditLevel; + } + } + + // Default to STANDARD if somehow we didn't match + return STANDARD; + } +} \ No newline at end of file diff --git a/proprietary/src/main/java/stirling/software/proprietary/audit/AuditUtils.java b/proprietary/src/main/java/stirling/software/proprietary/audit/AuditUtils.java new file mode 100644 index 000000000..35153d956 --- /dev/null +++ b/proprietary/src/main/java/stirling/software/proprietary/audit/AuditUtils.java @@ -0,0 +1,375 @@ +package stirling.software.proprietary.audit; + +import lombok.extern.slf4j.Slf4j; +import org.aspectj.lang.ProceedingJoinPoint; +import org.aspectj.lang.reflect.MethodSignature; +import org.slf4j.MDC; +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 stirling.software.common.util.RequestUriUtils; +import stirling.software.proprietary.config.AuditConfigurationProperties; + +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import java.lang.reflect.Method; +import java.time.Instant; +import java.util.Arrays; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; +import java.util.stream.IntStream; + +import org.apache.commons.lang3.StringUtils; + +/** + * Shared utilities for audit aspects to ensure consistent behavior + * across different audit mechanisms. + */ +@Slf4j +public class AuditUtils { + + /** + * Create a standard audit data map with common attributes based on the current audit level + * + * @param joinPoint The AspectJ join point + * @param auditLevel The current audit level + * @return A map with standard audit data + */ + public static Map createBaseAuditData(ProceedingJoinPoint joinPoint, AuditLevel auditLevel) { + Map data = new HashMap<>(); + + // Common data for all levels + data.put("timestamp", Instant.now().toString()); + + // Add principal if available + Authentication auth = SecurityContextHolder.getContext().getAuthentication(); + if (auth != null && auth.getName() != null) { + data.put("principal", auth.getName()); + } else { + data.put("principal", "system"); + } + + // Add class name and method name only at VERBOSE level + if (auditLevel.includes(AuditLevel.VERBOSE)) { + data.put("className", joinPoint.getTarget().getClass().getName()); + data.put("methodName", ((MethodSignature) joinPoint.getSignature()).getMethod().getName()); + } + + return data; + } + + /** + * Add HTTP-specific information to the audit data if available + * + * @param data The existing audit data map + * @param httpMethod The HTTP method (GET, POST, etc.) + * @param path The request path + * @param auditLevel The current audit level + */ + public static void addHttpData(Map data, String httpMethod, String path, AuditLevel auditLevel) { + if (httpMethod == null || path == null) { + return; // Skip if we don't have basic HTTP info + } + + // BASIC level HTTP data + data.put("httpMethod", httpMethod); + data.put("path", path); + + // Get request attributes safely + ServletRequestAttributes attrs = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes(); + if (attrs == null) { + return; // No request context available + } + + HttpServletRequest req = attrs.getRequest(); + if (req == null) { + return; // No request available + } + + // STANDARD level HTTP data + if (auditLevel.includes(AuditLevel.STANDARD)) { + data.put("clientIp", req.getRemoteAddr()); + data.put("sessionId", req.getSession(false) != null ? req.getSession(false).getId() : null); + data.put("requestId", MDC.get("requestId")); + + // Form data for POST/PUT/PATCH + if (("POST".equalsIgnoreCase(httpMethod) || + "PUT".equalsIgnoreCase(httpMethod) || + "PATCH".equalsIgnoreCase(httpMethod)) && req.getContentType() != null) { + + String contentType = req.getContentType(); + if (contentType.contains("application/x-www-form-urlencoded") || + contentType.contains("multipart/form-data")) { + + Map params = new HashMap<>(req.getParameterMap()); + // Remove CSRF token from logged parameters + params.remove("_csrf"); + + if (!params.isEmpty()) { + data.put("formParams", params); + } + } + } + } + } + + /** + * Add file information to the audit data if available + * + * @param data The existing audit data map + * @param joinPoint The AspectJ join point + * @param auditLevel The current audit level + */ + public static void addFileData(Map data, ProceedingJoinPoint joinPoint, AuditLevel auditLevel) { + if (auditLevel.includes(AuditLevel.STANDARD)) { + List files = Arrays.stream(joinPoint.getArgs()) + .filter(a -> a instanceof MultipartFile) + .map(a -> (MultipartFile)a) + .collect(Collectors.toList()); + + if (!files.isEmpty()) { + List> fileInfos = files.stream().map(f -> { + Map m = new HashMap<>(); + m.put("name", f.getOriginalFilename()); + m.put("size", f.getSize()); + m.put("type", f.getContentType()); + return m; + }).collect(Collectors.toList()); + + data.put("files", fileInfos); + } + } + } + + /** + * Add method arguments to the audit data + * + * @param data The existing audit data map + * @param joinPoint The AspectJ join point + * @param auditLevel The current audit level + */ + public static void addMethodArguments(Map data, ProceedingJoinPoint joinPoint, AuditLevel auditLevel) { + if (auditLevel.includes(AuditLevel.VERBOSE)) { + MethodSignature sig = (MethodSignature) joinPoint.getSignature(); + String[] names = sig.getParameterNames(); + Object[] vals = joinPoint.getArgs(); + if (names != null && vals != null) { + IntStream.range(0, names.length) + .forEach(i -> { + if (vals[i] != null) { + // Convert objects to safe string representation + data.put("arg_" + names[i], safeToString(vals[i], 500)); + } else { + data.put("arg_" + names[i], null); + } + }); + } + } + } + + /** + * Safely convert an object to string with size limiting + * + * @param obj The object to convert + * @param maxLength Maximum length of the resulting string + * @return A safe string representation, truncated if needed + */ + public static String safeToString(Object obj, int maxLength) { + if (obj == null) { + return "null"; + } + + String result; + try { + // Handle common types directly to avoid toString() overhead + if (obj instanceof String) { + result = (String) obj; + } else if (obj instanceof Number || obj instanceof Boolean) { + result = obj.toString(); + } else if (obj instanceof byte[]) { + result = "[binary data length=" + ((byte[]) obj).length + "]"; + } else { + // For complex objects, use toString but handle exceptions + result = obj.toString(); + } + + // Truncate if necessary + if (result != null && result.length() > maxLength) { + return StringUtils.truncate(result, maxLength - 3) + "..."; + } + + return result; + } catch (Exception e) { + // If toString() fails, return the class name + return "[" + obj.getClass().getName() + " - toString() failed]"; + } + } + + /** + * Determine if a method should be audited based on config and annotation + * + * @param method The method to check + * @param auditConfig The audit configuration + * @return true if the method should be audited + */ + public static boolean shouldAudit(Method method, AuditConfigurationProperties auditConfig) { + // First check if audit is globally enabled - fast path + if (!auditConfig.isEnabled()) { + return false; + } + + // Check for annotation override + Audited auditedAnnotation = method.getAnnotation(Audited.class); + AuditLevel requiredLevel = (auditedAnnotation != null) + ? auditedAnnotation.level() + : AuditLevel.BASIC; + + // Check if the required level is enabled + return auditConfig.getAuditLevel().includes(requiredLevel); + } + + /** + * Add timing and response status data to the audit record + * + * @param data The audit data to add to + * @param startTime The start time in milliseconds + * @param response The HTTP response (may be null for non-HTTP methods) + * @param level The current audit level + * @param isHttpRequest Whether this is an HTTP request (controller) or a regular method call + */ + public static void addTimingData(Map data, long startTime, HttpServletResponse response, AuditLevel level, boolean isHttpRequest) { + if (level.includes(AuditLevel.STANDARD)) { + // For HTTP requests, let ControllerAuditAspect handle timing separately + // For non-HTTP methods, add execution time here + if (!isHttpRequest) { + data.put("latencyMs", System.currentTimeMillis() - startTime); + } + + // Add HTTP status code if available + if (response != null) { + try { + data.put("statusCode", response.getStatus()); + } catch (Exception e) { + // Ignore - response might be in an inconsistent state + } + } + } + } + + /** + * Resolve the event type to use for auditing, considering annotations and context + * + * @param method The method being audited + * @param controller The controller class + * @param path The request path (may be null for non-HTTP methods) + * @param httpMethod The HTTP method (may be null for non-HTTP methods) + * @param annotation The @Audited annotation (may be null) + * @return The resolved event type (never null) + */ + public static AuditEventType resolveEventType(Method method, Class controller, String path, String httpMethod, Audited annotation) { + // First check if we have an explicit annotation + if (annotation != null && annotation.type() != AuditEventType.HTTP_REQUEST) { + return annotation.type(); + } + + // For HTTP methods, infer based on controller and path + if (httpMethod != null && path != null) { + String cls = controller.getSimpleName().toLowerCase(); + String pkg = controller.getPackage().getName().toLowerCase(); + + 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") + || path.matches("(?i).*/(upload|download)/.*")) { + 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(); + String pkg = controller.getPackage().getName().toLowerCase(); + + 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") + || path.matches("(?i).*/(upload|download)/.*")) { + 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()); + } +} \ No newline at end of file diff --git a/proprietary/src/main/java/stirling/software/proprietary/audit/Audited.java b/proprietary/src/main/java/stirling/software/proprietary/audit/Audited.java new file mode 100644 index 000000000..dff976d8e --- /dev/null +++ b/proprietary/src/main/java/stirling/software/proprietary/audit/Audited.java @@ -0,0 +1,67 @@ +package stirling.software.proprietary.audit; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/** + * Annotation for methods that should be audited. + * + * Usage: + * + *
+ * {@code 
+ * @Audited(type = AuditEventType.USER_REGISTRATION, level = AuditLevel.BASIC)
+ * public void registerUser(String username) {
+ *    // Method implementation
+ * }
+ * }
+ * 
+ * + * For backward compatibility, string-based event types are still supported: + * + *
+ * {@code 
+ * @Audited(typeString = "CUSTOM_EVENT_TYPE", level = AuditLevel.BASIC)
+ * public void customOperation() {
+ *    // Method implementation
+ * }
+ * }
+ * 
+ */ +@Target(ElementType.METHOD) +@Retention(RetentionPolicy.RUNTIME) +public @interface Audited { + + /** + * The type of audit event using the standardized AuditEventType enum. + * This is the preferred way to specify the event type. + * + * If both type() and typeString() are specified, type() takes precedence. + */ + AuditEventType type() default AuditEventType.HTTP_REQUEST; + + /** + * The type of audit event as a string (e.g., "FILE_UPLOAD", "USER_REGISTRATION"). + * Provided for backward compatibility and custom event types not in the enum. + * + * If both type() and typeString() are specified, type() takes precedence. + */ + String typeString() default ""; + + /** + * The audit level at which this event should be logged + */ + AuditLevel level() default AuditLevel.STANDARD; + + /** + * Should method arguments be included in the audit event + */ + boolean includeArgs() default true; + + /** + * Should the method return value be included in the audit event + */ + boolean includeResult() default false; +} \ No newline at end of file diff --git a/proprietary/src/main/java/stirling/software/proprietary/audit/ControllerAuditAspect.java b/proprietary/src/main/java/stirling/software/proprietary/audit/ControllerAuditAspect.java new file mode 100644 index 000000000..6f3990a68 --- /dev/null +++ b/proprietary/src/main/java/stirling/software/proprietary/audit/ControllerAuditAspect.java @@ -0,0 +1,211 @@ +package stirling.software.proprietary.audit; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.apache.commons.lang3.StringUtils; +import org.aspectj.lang.ProceedingJoinPoint; +import org.aspectj.lang.annotation.Around; +import org.aspectj.lang.annotation.Aspect; +import org.aspectj.lang.reflect.MethodSignature; +import org.springframework.stereotype.Component; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PatchMapping; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.context.request.RequestContextHolder; +import org.springframework.web.context.request.ServletRequestAttributes; + +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import stirling.software.proprietary.config.AuditConfigurationProperties; +import stirling.software.proprietary.service.AuditService; + +import java.lang.annotation.Annotation; +import java.lang.reflect.Method; +import java.util.HashMap; +import java.util.Map; + +/** + * Aspect for automatically auditing controller methods with web mappings + * (GetMapping, PostMapping, etc.) + */ +@Aspect +@Component +@Slf4j +@RequiredArgsConstructor +public class ControllerAuditAspect { + + private final AuditService auditService; + private final AuditConfigurationProperties auditConfig; + + + @Around("execution(* org.springframework.web.servlet.resource.ResourceHttpRequestHandler.handleRequest(..))") + public Object auditStaticResource(ProceedingJoinPoint jp) throws Throwable { + return auditController(jp, "GET"); + } + /** + * Intercept all methods with GetMapping annotation + */ + @Around("@annotation(org.springframework.web.bind.annotation.GetMapping)") + public Object auditGetMethod(ProceedingJoinPoint joinPoint) throws Throwable { + return auditController(joinPoint, "GET"); + } + + /** + * Intercept all methods with PostMapping annotation + */ + @Around("@annotation(org.springframework.web.bind.annotation.PostMapping)") + public Object auditPostMethod(ProceedingJoinPoint joinPoint) throws Throwable { + return auditController(joinPoint, "POST"); + } + + /** + * Intercept all methods with PutMapping annotation + */ + @Around("@annotation(org.springframework.web.bind.annotation.PutMapping)") + public Object auditPutMethod(ProceedingJoinPoint joinPoint) throws Throwable { + return auditController(joinPoint, "PUT"); + } + + /** + * Intercept all methods with DeleteMapping annotation + */ + @Around("@annotation(org.springframework.web.bind.annotation.DeleteMapping)") + public Object auditDeleteMethod(ProceedingJoinPoint joinPoint) throws Throwable { + return auditController(joinPoint, "DELETE"); + } + + /** + * Intercept all methods with PatchMapping annotation + */ + @Around("@annotation(org.springframework.web.bind.annotation.PatchMapping)") + public Object auditPatchMethod(ProceedingJoinPoint joinPoint) throws Throwable { + return auditController(joinPoint, "PATCH"); + } + + private Object auditController(ProceedingJoinPoint joinPoint, String httpMethod) throws Throwable { + MethodSignature sig = (MethodSignature) joinPoint.getSignature(); + Method method = sig.getMethod(); + + // Fast path: check if auditing is enabled before doing any work + // This avoids all data collection if auditing is disabled + if (!AuditUtils.shouldAudit(method, auditConfig)) { + return joinPoint.proceed(); + } + + // Check if method is explicitly annotated with @Audited + Audited auditedAnnotation = method.getAnnotation(Audited.class); + AuditLevel level = auditConfig.getAuditLevel(); + + // If @Audited annotation is present, respect its level setting + if (auditedAnnotation != null) { + // Use the level from annotation if it's stricter than global level + level = auditedAnnotation.level(); + } + + String path = getRequestPath(method, httpMethod); + + // Skip static GET resources + if ("GET".equals(httpMethod)) { + HttpServletRequest maybe = AuditUtils.getCurrentRequest(); + if (maybe != null && AuditUtils.isStaticResourceRequest(maybe)) { + return joinPoint.proceed(); + } + } + + ServletRequestAttributes attrs = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes(); + HttpServletRequest req = attrs != null ? attrs.getRequest() : null; + HttpServletResponse resp = attrs != null ? attrs.getResponse() : null; + + long start = System.currentTimeMillis(); + + // Use AuditUtils to create the base audit data + Map data = AuditUtils.createBaseAuditData(joinPoint, level); + + // Add HTTP-specific information + AuditUtils.addHttpData(data, httpMethod, path, level); + + // Add file information if present + AuditUtils.addFileData(data, joinPoint, level); + + // Add method arguments if at VERBOSE level + if (level.includes(AuditLevel.VERBOSE)) { + AuditUtils.addMethodArguments(data, joinPoint, level); + } + + 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 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) { + String typeString = auditedAnnotation.typeString(); + if (eventType == AuditEventType.HTTP_REQUEST && StringUtils.isNotEmpty(typeString)) { + auditService.audit(typeString, data, level); + return result; + } + } + + // Use the enum type + auditService.audit(eventType, data, level); + } + return result; + } + + // Using AuditUtils.determineAuditEventType instead + + private String getRequestPath(Method method, String httpMethod) { + String base = ""; + RequestMapping cm = method.getDeclaringClass().getAnnotation(RequestMapping.class); + if (cm != null && cm.value().length > 0) base = cm.value()[0]; + String mp = ""; + Annotation ann = switch (httpMethod) { + case "GET" -> method.getAnnotation(GetMapping.class); + case "POST" -> method.getAnnotation(PostMapping.class); + case "PUT" -> method.getAnnotation(PutMapping.class); + case "DELETE" -> method.getAnnotation(DeleteMapping.class); + case "PATCH" -> method.getAnnotation(PatchMapping.class); + default -> null; + }; + if (ann instanceof GetMapping gm && gm.value().length > 0) mp = gm.value()[0]; + if (ann instanceof PostMapping pm && pm.value().length > 0) mp = pm.value()[0]; + if (ann instanceof PutMapping pum && pum.value().length > 0) mp = pum.value()[0]; + if (ann instanceof DeleteMapping dm && dm.value().length > 0) mp = dm.value()[0]; + if (ann instanceof PatchMapping pam && pam.value().length > 0) mp = pam.value()[0]; + return base + mp; + } + + // Using AuditUtils.getCurrentRequest instead +} diff --git a/proprietary/src/main/java/stirling/software/proprietary/config/AsyncConfig.java b/proprietary/src/main/java/stirling/software/proprietary/config/AsyncConfig.java new file mode 100644 index 000000000..2926b9e89 --- /dev/null +++ b/proprietary/src/main/java/stirling/software/proprietary/config/AsyncConfig.java @@ -0,0 +1,57 @@ +package stirling.software.proprietary.config; + +import org.slf4j.MDC; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.core.task.TaskDecorator; +import org.springframework.scheduling.annotation.EnableAsync; +import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor; + +import java.util.Map; +import java.util.concurrent.Executor; + +@Configuration +@EnableAsync +public class AsyncConfig { + + /** + * MDC context-propagating task decorator + * Copies MDC context from the caller thread to the async executor thread + */ + static class MDCContextTaskDecorator implements TaskDecorator { + @Override + public Runnable decorate(Runnable runnable) { + // Capture the MDC context from the current thread + Map contextMap = MDC.getCopyOfContextMap(); + + return () -> { + try { + // Set the captured context on the worker thread + if (contextMap != null) { + MDC.setContextMap(contextMap); + } + // Execute the task + runnable.run(); + } finally { + // Clear the context to prevent memory leaks + MDC.clear(); + } + }; + } + } + + @Bean(name = "auditExecutor") + public Executor auditExecutor() { + ThreadPoolTaskExecutor exec = new ThreadPoolTaskExecutor(); + exec.setCorePoolSize(2); + exec.setMaxPoolSize(8); + exec.setQueueCapacity(1_000); + exec.setThreadNamePrefix("audit-"); + + // Set the task decorator to propagate MDC context + exec.setTaskDecorator(new MDCContextTaskDecorator()); + + exec.initialize(); + return exec; + } +} \ No newline at end of file diff --git a/proprietary/src/main/java/stirling/software/proprietary/config/AuditConfigurationProperties.java b/proprietary/src/main/java/stirling/software/proprietary/config/AuditConfigurationProperties.java new file mode 100644 index 000000000..6e30fa4c8 --- /dev/null +++ b/proprietary/src/main/java/stirling/software/proprietary/config/AuditConfigurationProperties.java @@ -0,0 +1,68 @@ +package stirling.software.proprietary.config; + +import lombok.Getter; +import lombok.extern.slf4j.Slf4j; + +import org.springframework.core.Ordered; +import org.springframework.core.annotation.Order; +import org.springframework.stereotype.Component; +import stirling.software.common.model.ApplicationProperties; +import stirling.software.proprietary.audit.AuditLevel; + +/** + * Configuration properties for the audit system. + * Reads values from the ApplicationProperties under premium.enterpriseFeatures.audit + */ +@Slf4j +@Getter +@Component +@Order(Ordered.HIGHEST_PRECEDENCE + 10) +public class AuditConfigurationProperties { + + private final boolean enabled; + private final int level; + private final int retentionDays; + + public AuditConfigurationProperties(ApplicationProperties applicationProperties) { + ApplicationProperties.Premium.EnterpriseFeatures.Audit auditConfig = + applicationProperties.getPremium().getEnterpriseFeatures().getAudit(); + // Read values directly from configuration + this.enabled = auditConfig.isEnabled(); + + // Ensure level is within valid bounds (0-3) + int configLevel = auditConfig.getLevel(); + this.level = Math.min(Math.max(configLevel, 0), 3); + + // Retention days (0 means infinite) + this.retentionDays = auditConfig.getRetentionDays(); + + log.debug("Initialized audit configuration: enabled={}, level={}, retentionDays={} (0=infinite)", + this.enabled, this.level, this.retentionDays); + } + + /** + * Get the audit level as an enum + * @return The current AuditLevel + */ + public AuditLevel getAuditLevel() { + return AuditLevel.fromInt(level); + } + + /** + * Check if the current audit level includes the specified level + * @param requiredLevel The level to check against + * @return true if auditing is enabled and the current level includes the required level + */ + public boolean isLevelEnabled(AuditLevel requiredLevel) { + return enabled && getAuditLevel().includes(requiredLevel); + } + + /** + * Get the effective retention period in days + * @return The number of days to retain audit records, or -1 for infinite retention + */ + public int getEffectiveRetentionDays() { + // 0 means infinite retention + return retentionDays <= 0 ? -1 : retentionDays; + } +} \ No newline at end of file diff --git a/proprietary/src/main/java/stirling/software/proprietary/config/AuditJpaConfig.java b/proprietary/src/main/java/stirling/software/proprietary/config/AuditJpaConfig.java new file mode 100644 index 000000000..c0ca3e4b4 --- /dev/null +++ b/proprietary/src/main/java/stirling/software/proprietary/config/AuditJpaConfig.java @@ -0,0 +1,19 @@ +package stirling.software.proprietary.config; + +import org.springframework.context.annotation.Configuration; +import org.springframework.data.jpa.repository.config.EnableJpaRepositories; +import org.springframework.scheduling.annotation.EnableScheduling; +import org.springframework.transaction.annotation.EnableTransactionManagement; + +/** + * Configuration to explicitly enable JPA repositories and scheduling for the audit system. + */ +@Configuration +@EnableTransactionManagement +@EnableJpaRepositories(basePackages = "stirling.software.proprietary.repository") +@EnableScheduling +public class AuditJpaConfig { + // This configuration enables JPA repositories in the specified package + // and enables scheduling for audit cleanup tasks + // No additional beans or methods needed +} \ No newline at end of file diff --git a/proprietary/src/main/java/stirling/software/proprietary/config/CustomAuditEventRepository.java b/proprietary/src/main/java/stirling/software/proprietary/config/CustomAuditEventRepository.java new file mode 100644 index 000000000..bd9a86d89 --- /dev/null +++ b/proprietary/src/main/java/stirling/software/proprietary/config/CustomAuditEventRepository.java @@ -0,0 +1,74 @@ +package stirling.software.proprietary.config; + +import com.fasterxml.jackson.databind.ObjectMapper; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +import org.slf4j.MDC; +import org.springframework.boot.actuate.audit.AuditEvent; +import org.springframework.boot.actuate.audit.AuditEventRepository; +import org.springframework.context.annotation.Primary; +import org.springframework.scheduling.annotation.Async; +import org.springframework.stereotype.Component; +import org.springframework.util.CollectionUtils; +import stirling.software.proprietary.model.security.PersistentAuditEvent; +import stirling.software.proprietary.repository.PersistentAuditEventRepository; +import stirling.software.proprietary.util.SecretMasker; + +import java.time.Instant; +import java.util.List; +import java.util.Map; + +@Component +@Primary +@RequiredArgsConstructor +@Slf4j +public class CustomAuditEventRepository implements AuditEventRepository { + + private final PersistentAuditEventRepository repo; + private final ObjectMapper mapper; + + /* ── READ side intentionally inert (endpoint disabled) ── */ + @Override + public List find(String p, Instant after, String type) { + return List.of(); + } + + /* ── WRITE side (async) ───────────────────────────────── */ + @Async("auditExecutor") + @Override + public void add(AuditEvent ev) { + try { + Map clean = + CollectionUtils.isEmpty(ev.getData()) + ? Map.of() + : SecretMasker.mask(ev.getData()); + + + if (clean.isEmpty() || + (clean.size() == 1 && clean.containsKey("details"))) { + return; + } + String rid = MDC.get("requestId"); + + + if (rid != null) { + clean = new java.util.HashMap<>(clean); + clean.put("requestId", rid); + } + + String auditEventData = mapper.writeValueAsString(clean); + log.debug("AuditEvent data (JSON): {}",auditEventData); + + PersistentAuditEvent ent = PersistentAuditEvent.builder() + .principal(ev.getPrincipal()) + .type(ev.getType()) + .data(auditEventData) + .timestamp(ev.getTimestamp()) + .build(); + repo.save(ent); + } catch (Exception e) { + e.printStackTrace(); // fail-open + } + } +} \ No newline at end of file diff --git a/proprietary/src/main/java/stirling/software/proprietary/config/HttpRequestAuditPublisher.java b/proprietary/src/main/java/stirling/software/proprietary/config/HttpRequestAuditPublisher.java new file mode 100644 index 000000000..e69de29bb diff --git a/proprietary/src/main/java/stirling/software/proprietary/controller/AuditDashboardController.java b/proprietary/src/main/java/stirling/software/proprietary/controller/AuditDashboardController.java new file mode 100644 index 000000000..c871dbfc0 --- /dev/null +++ b/proprietary/src/main/java/stirling/software/proprietary/controller/AuditDashboardController.java @@ -0,0 +1,344 @@ +package stirling.software.proprietary.controller; + +import java.time.Instant; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.ZoneId; +import java.time.format.DateTimeFormatter; +import java.util.Arrays; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.stream.Collectors; + +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Sort; +import org.springframework.format.annotation.DateTimeFormat; +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.stereotype.Controller; +import org.springframework.ui.Model; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.ResponseBody; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; + +import jakarta.servlet.http.HttpServletRequest; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import stirling.software.proprietary.audit.AuditEventType; +import stirling.software.proprietary.audit.AuditLevel; +import stirling.software.proprietary.config.AuditConfigurationProperties; +import stirling.software.proprietary.model.security.PersistentAuditEvent; +import stirling.software.proprietary.repository.PersistentAuditEventRepository; +import stirling.software.proprietary.security.config.EnterpriseEndpoint; + +/** + * Controller for the audit dashboard. + * Admin-only access. + */ +@Slf4j +@Controller +@RequestMapping("/audit") +@PreAuthorize("hasRole('ADMIN')") +@RequiredArgsConstructor +@EnterpriseEndpoint +public class AuditDashboardController { + + private final PersistentAuditEventRepository auditRepository; + private final AuditConfigurationProperties auditConfig; + private final ObjectMapper objectMapper; + + /** + * Display the audit dashboard. + */ + @GetMapping + public String showDashboard(Model model) { + model.addAttribute("auditEnabled", auditConfig.isEnabled()); + model.addAttribute("auditLevel", auditConfig.getAuditLevel()); + model.addAttribute("auditLevelInt", auditConfig.getLevel()); + model.addAttribute("retentionDays", auditConfig.getRetentionDays()); + + // Add audit level enum values for display + model.addAttribute("auditLevels", AuditLevel.values()); + + // Add audit event types for the dropdown + model.addAttribute("auditEventTypes", AuditEventType.values()); + + return "audit/dashboard"; + } + + /** + * Get audit events data for the dashboard tables. + */ + @GetMapping("/data") + @ResponseBody + public Map getAuditData( + @RequestParam(value = "page", defaultValue = "0") int page, + @RequestParam(value = "size", defaultValue = "30") int size, + @RequestParam(value = "type", required = false) String type, + @RequestParam(value = "principal", required = false) String principal, + @RequestParam(value = "startDate", required = false) + @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate startDate, + @RequestParam(value = "endDate", required = false) + @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate endDate, HttpServletRequest request) { + + + Pageable pageable = PageRequest.of(page, size, Sort.by("timestamp").descending()); + Page events; + + String mode; + + if (type != null && principal != null && startDate != null && endDate != null) { + mode = "principal + type + startDate + endDate"; + Instant start = startDate.atStartOfDay(ZoneId.systemDefault()).toInstant(); + Instant end = endDate.plusDays(1).atStartOfDay(ZoneId.systemDefault()).toInstant(); + events = auditRepository.findByPrincipalAndTypeAndTimestampBetween(principal, type, start, end, pageable); + } else if (type != null && principal != null) { + mode = "principal + type"; + events = auditRepository.findByPrincipalAndType(principal, type, pageable); + } else if (type != null && startDate != null && endDate != null) { + mode = "type + startDate + endDate"; + Instant start = startDate.atStartOfDay(ZoneId.systemDefault()).toInstant(); + Instant end = endDate.plusDays(1).atStartOfDay(ZoneId.systemDefault()).toInstant(); + events = auditRepository.findByTypeAndTimestampBetween(type, start, end, pageable); + } else if (principal != null && startDate != null && endDate != null) { + mode = "principal + startDate + endDate"; + Instant start = startDate.atStartOfDay(ZoneId.systemDefault()).toInstant(); + Instant end = endDate.plusDays(1).atStartOfDay(ZoneId.systemDefault()).toInstant(); + events = auditRepository.findByPrincipalAndTimestampBetween(principal, start, end, pageable); + } else if (startDate != null && endDate != null) { + mode = "startDate + endDate"; + Instant start = startDate.atStartOfDay(ZoneId.systemDefault()).toInstant(); + Instant end = endDate.plusDays(1).atStartOfDay(ZoneId.systemDefault()).toInstant(); + events = auditRepository.findByTimestampBetween(start, end, pageable); + } else if (type != null) { + mode = "type"; + events = auditRepository.findByType(type, pageable); + } else if (principal != null) { + mode = "principal"; + events = auditRepository.findByPrincipal(principal, pageable); + } else { + mode = "all"; + events = auditRepository.findAll(pageable); + } + + // Logging + List content = events.getContent(); + + Map response = new HashMap<>(); + response.put("content", content); + response.put("totalPages", events.getTotalPages()); + response.put("totalElements", events.getTotalElements()); + response.put("currentPage", events.getNumber()); + + return response; + } + + + /** + * Get statistics for charts. + */ + @GetMapping("/stats") + @ResponseBody + public Map getAuditStats( + @RequestParam(value = "days", defaultValue = "7") int days) { + + // Get events from the last X days + Instant startDate = Instant.now().minus(java.time.Duration.ofDays(days)); + List events = auditRepository.findByTimestampAfter(startDate); + + // Count events by type + Map eventsByType = events.stream() + .collect(Collectors.groupingBy(PersistentAuditEvent::getType, Collectors.counting())); + + // Count events by principal + Map eventsByPrincipal = events.stream() + .collect(Collectors.groupingBy(PersistentAuditEvent::getPrincipal, Collectors.counting())); + + // Count events by day + Map eventsByDay = events.stream() + .collect(Collectors.groupingBy( + e -> LocalDateTime.ofInstant(e.getTimestamp(), ZoneId.systemDefault()) + .format(DateTimeFormatter.ISO_LOCAL_DATE), + Collectors.counting())); + + Map stats = new HashMap<>(); + stats.put("eventsByType", eventsByType); + stats.put("eventsByPrincipal", eventsByPrincipal); + stats.put("eventsByDay", eventsByDay); + stats.put("totalEvents", events.size()); + + return stats; + } + + /** + * Get all unique event types from the database for filtering. + */ + @GetMapping("/types") + @ResponseBody + public List getAuditTypes() { + // Get distinct event types from the database + List dbTypes = auditRepository.findDistinctEventTypes(); + + // Include standard enum types in case they're not in the database yet + List enumTypes = Arrays.stream(AuditEventType.values()) + .map(AuditEventType::name) + .collect(Collectors.toList()); + + // Combine both sources, remove duplicates, and sort + Set combinedTypes = new HashSet<>(); + combinedTypes.addAll(dbTypes); + combinedTypes.addAll(enumTypes); + + return combinedTypes.stream().sorted().collect(Collectors.toList()); + } + + /** + * Export audit data as CSV. + */ + @GetMapping("/export") + public ResponseEntity exportAuditData( + @RequestParam(value = "type", required = false) String type, + @RequestParam(value = "principal", required = false) String principal, + @RequestParam(value = "startDate", required = false) + @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate startDate, + @RequestParam(value = "endDate", required = false) + @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate endDate) { + + // Get data with same filtering as getAuditData + List events; + + if (type != null && principal != null && startDate != null && endDate != null) { + Instant start = startDate.atStartOfDay(ZoneId.systemDefault()).toInstant(); + Instant end = endDate.plusDays(1).atStartOfDay(ZoneId.systemDefault()).toInstant(); + events = auditRepository.findAllByPrincipalAndTypeAndTimestampBetweenForExport( + principal, type, start, end); + } else if (type != null && principal != null) { + events = auditRepository.findAllByPrincipalAndTypeForExport(principal, type); + } else if (type != null && startDate != null && endDate != null) { + Instant start = startDate.atStartOfDay(ZoneId.systemDefault()).toInstant(); + Instant end = endDate.plusDays(1).atStartOfDay(ZoneId.systemDefault()).toInstant(); + events = auditRepository.findAllByTypeAndTimestampBetweenForExport(type, start, end); + } else if (principal != null && startDate != null && endDate != null) { + Instant start = startDate.atStartOfDay(ZoneId.systemDefault()).toInstant(); + Instant end = endDate.plusDays(1).atStartOfDay(ZoneId.systemDefault()).toInstant(); + events = auditRepository.findAllByPrincipalAndTimestampBetweenForExport(principal, start, end); + } else if (startDate != null && endDate != null) { + Instant start = startDate.atStartOfDay(ZoneId.systemDefault()).toInstant(); + Instant end = endDate.plusDays(1).atStartOfDay(ZoneId.systemDefault()).toInstant(); + events = auditRepository.findAllByTimestampBetweenForExport(start, end); + } else if (type != null) { + events = auditRepository.findByTypeForExport(type); + } else if (principal != null) { + events = auditRepository.findAllByPrincipalForExport(principal); + } else { + events = auditRepository.findAll(); + } + + // Convert to CSV + StringBuilder csv = new StringBuilder(); + csv.append("ID,Principal,Type,Timestamp,Data\n"); + + DateTimeFormatter formatter = DateTimeFormatter.ISO_INSTANT; + + for (PersistentAuditEvent event : events) { + csv.append(event.getId()).append(","); + csv.append(escapeCSV(event.getPrincipal())).append(","); + csv.append(escapeCSV(event.getType())).append(","); + csv.append(formatter.format(event.getTimestamp())).append(","); + csv.append(escapeCSV(event.getData())).append("\n"); + } + + byte[] csvBytes = csv.toString().getBytes(); + + // Set up HTTP headers for download + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.APPLICATION_OCTET_STREAM); + headers.setContentDispositionFormData("attachment", "audit_export.csv"); + + return ResponseEntity.ok() + .headers(headers) + .body(csvBytes); + } + + /** + * Export audit data as JSON. + */ + @GetMapping("/export/json") + public ResponseEntity exportAuditDataJson( + @RequestParam(value = "type", required = false) String type, + @RequestParam(value = "principal", required = false) String principal, + @RequestParam(value = "startDate", required = false) + @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate startDate, + @RequestParam(value = "endDate", required = false) + @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate endDate) { + + // Get data with same filtering as getAuditData + List events; + + if (type != null && principal != null && startDate != null && endDate != null) { + Instant start = startDate.atStartOfDay(ZoneId.systemDefault()).toInstant(); + Instant end = endDate.plusDays(1).atStartOfDay(ZoneId.systemDefault()).toInstant(); + events = auditRepository.findAllByPrincipalAndTypeAndTimestampBetweenForExport( + principal, type, start, end); + } else if (type != null && principal != null) { + events = auditRepository.findAllByPrincipalAndTypeForExport(principal, type); + } else if (type != null && startDate != null && endDate != null) { + Instant start = startDate.atStartOfDay(ZoneId.systemDefault()).toInstant(); + Instant end = endDate.plusDays(1).atStartOfDay(ZoneId.systemDefault()).toInstant(); + events = auditRepository.findAllByTypeAndTimestampBetweenForExport(type, start, end); + } else if (principal != null && startDate != null && endDate != null) { + Instant start = startDate.atStartOfDay(ZoneId.systemDefault()).toInstant(); + Instant end = endDate.plusDays(1).atStartOfDay(ZoneId.systemDefault()).toInstant(); + events = auditRepository.findAllByPrincipalAndTimestampBetweenForExport(principal, start, end); + } else if (startDate != null && endDate != null) { + Instant start = startDate.atStartOfDay(ZoneId.systemDefault()).toInstant(); + Instant end = endDate.plusDays(1).atStartOfDay(ZoneId.systemDefault()).toInstant(); + events = auditRepository.findAllByTimestampBetweenForExport(start, end); + } else if (type != null) { + events = auditRepository.findByTypeForExport(type); + } else if (principal != null) { + events = auditRepository.findAllByPrincipalForExport(principal); + } else { + events = auditRepository.findAll(); + } + + // Convert to JSON + try { + byte[] jsonBytes = objectMapper.writeValueAsBytes(events); + + // Set up HTTP headers for download + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.APPLICATION_JSON); + headers.setContentDispositionFormData("attachment", "audit_export.json"); + + return ResponseEntity.ok() + .headers(headers) + .body(jsonBytes); + } catch (JsonProcessingException e) { + log.error("Error serializing audit events to JSON", e); + return ResponseEntity.internalServerError().build(); + } + } + + /** + * Helper method to escape CSV fields. + */ + private String escapeCSV(String field) { + if (field == null) { + return ""; + } + // Replace double quotes with two double quotes and wrap in quotes + return "\"" + field.replace("\"", "\"\"") + "\""; + } +} \ No newline at end of file diff --git a/proprietary/src/main/java/stirling/software/proprietary/model/security/PersistentAuditEvent.java b/proprietary/src/main/java/stirling/software/proprietary/model/security/PersistentAuditEvent.java new file mode 100644 index 000000000..8fb520156 --- /dev/null +++ b/proprietary/src/main/java/stirling/software/proprietary/model/security/PersistentAuditEvent.java @@ -0,0 +1,34 @@ +package stirling.software.proprietary.model.security; + +import jakarta.persistence.*; +import lombok.*; +import org.hibernate.annotations.Index; + +import java.time.Instant; + +@Entity +@Table( + name = "audit_events", + indexes = { + @jakarta.persistence.Index(name = "idx_audit_timestamp", columnList = "timestamp"), + @jakarta.persistence.Index(name = "idx_audit_principal", columnList = "principal"), + @jakarta.persistence.Index(name = "idx_audit_type", columnList = "type"), + @jakarta.persistence.Index(name = "idx_audit_principal_type", columnList = "principal,type"), + @jakarta.persistence.Index(name = "idx_audit_type_timestamp", columnList = "type,timestamp") + } +) +@Data @Builder @NoArgsConstructor @AllArgsConstructor +public class PersistentAuditEvent { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + private String principal; + private String type; + + @Lob + private String data; // JSON blob + + private Instant timestamp; +} \ No newline at end of file diff --git a/proprietary/src/main/java/stirling/software/proprietary/repository/PersistentAuditEventRepository.java b/proprietary/src/main/java/stirling/software/proprietary/repository/PersistentAuditEventRepository.java new file mode 100644 index 000000000..60d1ee3ed --- /dev/null +++ b/proprietary/src/main/java/stirling/software/proprietary/repository/PersistentAuditEventRepository.java @@ -0,0 +1,72 @@ +package stirling.software.proprietary.repository; + +import java.time.Instant; +import java.util.List; + +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import org.springframework.stereotype.Repository; +import org.springframework.transaction.annotation.Transactional; + +import stirling.software.proprietary.model.security.PersistentAuditEvent; + +@Repository +public interface PersistentAuditEventRepository + extends JpaRepository { + + // Basic queries + @Query("SELECT e FROM PersistentAuditEvent e WHERE UPPER(e.principal) LIKE UPPER(CONCAT('%', :principal, '%'))") + Page findByPrincipal(@Param("principal") String principal, Pageable pageable); + Page findByType(String type, Pageable pageable); + Page findByTimestampBetween(Instant startDate, Instant endDate, Pageable pageable); + @Query("SELECT e FROM PersistentAuditEvent e WHERE UPPER(e.principal) LIKE UPPER(CONCAT('%', :principal, '%')) AND e.type = :type") + Page findByPrincipalAndType(@Param("principal") String principal, @Param("type") String type, Pageable pageable); + @Query("SELECT e FROM PersistentAuditEvent e WHERE UPPER(e.principal) LIKE UPPER(CONCAT('%', :principal, '%')) AND e.timestamp BETWEEN :startDate AND :endDate") + Page findByPrincipalAndTimestampBetween(@Param("principal") String principal, @Param("startDate") Instant startDate, @Param("endDate") Instant endDate, Pageable pageable); + Page findByTypeAndTimestampBetween(String type, Instant startDate, Instant endDate, Pageable pageable); + @Query("SELECT e FROM PersistentAuditEvent e WHERE UPPER(e.principal) LIKE UPPER(CONCAT('%', :principal, '%')) AND e.type = :type AND e.timestamp BETWEEN :startDate AND :endDate") + Page findByPrincipalAndTypeAndTimestampBetween(@Param("principal") String principal, @Param("type") String type, @Param("startDate") Instant startDate, @Param("endDate") Instant endDate, Pageable pageable); + + // Non-paged versions for export + @Query("SELECT e FROM PersistentAuditEvent e WHERE UPPER(e.principal) LIKE UPPER(CONCAT('%', :principal, '%'))") + List findAllByPrincipalForExport(@Param("principal") String principal); + @Query("SELECT e FROM PersistentAuditEvent e WHERE e.type = :type") + List findByTypeForExport(@Param("type") String type); + @Query("SELECT e FROM PersistentAuditEvent e WHERE e.timestamp BETWEEN :startDate AND :endDate") + List findAllByTimestampBetweenForExport(@Param("startDate") Instant startDate, @Param("endDate") Instant endDate); + @Query("SELECT e FROM PersistentAuditEvent e WHERE e.timestamp > :startDate") + List findByTimestampAfter(@Param("startDate") Instant startDate); + @Query("SELECT e FROM PersistentAuditEvent e WHERE UPPER(e.principal) LIKE UPPER(CONCAT('%', :principal, '%')) AND e.type = :type") + List findAllByPrincipalAndTypeForExport(@Param("principal") String principal, @Param("type") String type); + @Query("SELECT e FROM PersistentAuditEvent e WHERE UPPER(e.principal) LIKE UPPER(CONCAT('%', :principal, '%')) AND e.timestamp BETWEEN :startDate AND :endDate") + List findAllByPrincipalAndTimestampBetweenForExport(@Param("principal") String principal, @Param("startDate") Instant startDate, @Param("endDate") Instant endDate); + @Query("SELECT e FROM PersistentAuditEvent e WHERE e.type = :type AND e.timestamp BETWEEN :startDate AND :endDate") + List findAllByTypeAndTimestampBetweenForExport(@Param("type") String type, @Param("startDate") Instant startDate, @Param("endDate") Instant endDate); + @Query("SELECT e FROM PersistentAuditEvent e WHERE UPPER(e.principal) LIKE UPPER(CONCAT('%', :principal, '%')) AND e.type = :type AND e.timestamp BETWEEN :startDate AND :endDate") + List findAllByPrincipalAndTypeAndTimestampBetweenForExport(@Param("principal") String principal, @Param("type") String type, @Param("startDate") Instant startDate, @Param("endDate") Instant endDate); + + // Cleanup queries + @Query("DELETE FROM PersistentAuditEvent e WHERE e.timestamp < ?1") + @Modifying + @Transactional + int deleteByTimestampBefore(Instant cutoffDate); + + // Find IDs for batch deletion - using JPQL with setMaxResults instead of native query + @Query("SELECT e.id FROM PersistentAuditEvent e WHERE e.timestamp < ?1 ORDER BY e.id") + List findIdsForBatchDeletion(Instant cutoffDate, Pageable pageable); + + // Stats queries + @Query("SELECT e.type, COUNT(e) FROM PersistentAuditEvent e GROUP BY e.type") + List countByType(); + + @Query("SELECT e.principal, COUNT(e) FROM PersistentAuditEvent e GROUP BY e.principal") + List countByPrincipal(); + + // Get distinct event types for filtering + @Query("SELECT DISTINCT e.type FROM PersistentAuditEvent e ORDER BY e.type") + List findDistinctEventTypes(); +} \ No newline at end of file diff --git a/proprietary/src/main/java/stirling/software/proprietary/security/CustomAuthenticationFailureHandler.java b/proprietary/src/main/java/stirling/software/proprietary/security/CustomAuthenticationFailureHandler.java index ee726b9fb..3be32c367 100644 --- a/proprietary/src/main/java/stirling/software/proprietary/security/CustomAuthenticationFailureHandler.java +++ b/proprietary/src/main/java/stirling/software/proprietary/security/CustomAuthenticationFailureHandler.java @@ -4,6 +4,8 @@ import jakarta.servlet.ServletException; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; import java.io.IOException; +import java.util.HashMap; +import java.util.Map; import java.util.Optional; import lombok.extern.slf4j.Slf4j; import org.springframework.security.authentication.BadCredentialsException; @@ -13,6 +15,16 @@ import org.springframework.security.authentication.LockedException; import org.springframework.security.core.AuthenticationException; import org.springframework.security.core.userdetails.UsernameNotFoundException; import org.springframework.security.web.authentication.SimpleUrlAuthenticationFailureHandler; + +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; + +import lombok.extern.slf4j.Slf4j; + +import stirling.software.proprietary.audit.AuditEventType; +import stirling.software.proprietary.audit.AuditLevel; +import stirling.software.proprietary.audit.Audited; import stirling.software.proprietary.security.model.User; import stirling.software.proprietary.security.service.LoginAttemptService; import stirling.software.proprietary.security.service.UserService; @@ -31,6 +43,7 @@ public class CustomAuthenticationFailureHandler extends SimpleUrlAuthenticationF } @Override + @Audited(type = AuditEventType.USER_FAILED_LOGIN, level = AuditLevel.BASIC) public void onAuthenticationFailure( HttpServletRequest request, HttpServletResponse response, diff --git a/proprietary/src/main/java/stirling/software/proprietary/security/CustomAuthenticationSuccessHandler.java b/proprietary/src/main/java/stirling/software/proprietary/security/CustomAuthenticationSuccessHandler.java index b9379ec74..24e0a6bbf 100644 --- a/proprietary/src/main/java/stirling/software/proprietary/security/CustomAuthenticationSuccessHandler.java +++ b/proprietary/src/main/java/stirling/software/proprietary/security/CustomAuthenticationSuccessHandler.java @@ -1,4 +1,11 @@ package stirling.software.proprietary.security; +import java.io.IOException; +import java.util.HashMap; +import java.util.Map; + +import org.springframework.security.core.Authentication; +import org.springframework.security.web.authentication.SavedRequestAwareAuthenticationSuccessHandler; +import org.springframework.security.web.savedrequest.SavedRequest; import jakarta.servlet.ServletException; import jakarta.servlet.http.HttpServletRequest; @@ -10,6 +17,9 @@ import org.springframework.security.core.Authentication; import org.springframework.security.web.authentication.SavedRequestAwareAuthenticationSuccessHandler; import org.springframework.security.web.savedrequest.SavedRequest; import stirling.software.common.util.RequestUriUtils; +import stirling.software.proprietary.audit.AuditEventType; +import stirling.software.proprietary.audit.AuditLevel; +import stirling.software.proprietary.audit.Audited; import stirling.software.proprietary.security.service.LoginAttemptService; import stirling.software.proprietary.security.service.UserService; @@ -27,6 +37,7 @@ public class CustomAuthenticationSuccessHandler } @Override + @Audited(type = AuditEventType.USER_LOGIN, level = AuditLevel.BASIC) public void onAuthenticationSuccess( HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws ServletException, IOException { diff --git a/proprietary/src/main/java/stirling/software/proprietary/security/CustomLogoutSuccessHandler.java b/proprietary/src/main/java/stirling/software/proprietary/security/CustomLogoutSuccessHandler.java index 96101ffd2..8aa47a7fa 100644 --- a/proprietary/src/main/java/stirling/software/proprietary/security/CustomLogoutSuccessHandler.java +++ b/proprietary/src/main/java/stirling/software/proprietary/security/CustomLogoutSuccessHandler.java @@ -23,6 +23,9 @@ import stirling.software.common.model.ApplicationProperties.Security.OAUTH2; import stirling.software.common.model.ApplicationProperties.Security.SAML2; import stirling.software.common.model.oauth2.KeycloakProvider; import stirling.software.common.util.UrlUtils; +import stirling.software.proprietary.audit.AuditEventType; +import stirling.software.proprietary.audit.AuditLevel; +import stirling.software.proprietary.audit.Audited; import stirling.software.proprietary.security.saml2.CertificateUtils; import stirling.software.proprietary.security.saml2.CustomSaml2AuthenticatedPrincipal; @@ -37,6 +40,7 @@ public class CustomLogoutSuccessHandler extends SimpleUrlLogoutSuccessHandler { private final AppConfig appConfig; @Override + @Audited(type = AuditEventType.USER_LOGOUT, level = AuditLevel.BASIC) public void onLogoutSuccess( HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException { diff --git a/proprietary/src/main/java/stirling/software/proprietary/security/InitialSecuritySetup.java b/proprietary/src/main/java/stirling/software/proprietary/security/InitialSecuritySetup.java index 67e32c76a..306ffab2f 100644 --- a/proprietary/src/main/java/stirling/software/proprietary/security/InitialSecuritySetup.java +++ b/proprietary/src/main/java/stirling/software/proprietary/security/InitialSecuritySetup.java @@ -62,8 +62,10 @@ public class InitialSecuritySetup { } userService.saveAll(usersWithoutTeam); // batch save + if(usersWithoutTeam != null && !usersWithoutTeam.isEmpty()) { log.info( "Assigned {} user(s) without a team to the default team.", usersWithoutTeam.size()); + } } private void initializeAdminUser() throws SQLException, UnsupportedProviderException { diff --git a/proprietary/src/main/java/stirling/software/proprietary/security/config/EnterpriseEndpoint.java b/proprietary/src/main/java/stirling/software/proprietary/security/config/EnterpriseEndpoint.java new file mode 100644 index 000000000..800867017 --- /dev/null +++ b/proprietary/src/main/java/stirling/software/proprietary/security/config/EnterpriseEndpoint.java @@ -0,0 +1,11 @@ +package stirling.software.proprietary.security.config; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/** Annotation to mark endpoints that require an Enterprise license. */ +@Target({ElementType.METHOD, ElementType.TYPE}) +@Retention(RetentionPolicy.RUNTIME) +public @interface EnterpriseEndpoint {} \ No newline at end of file diff --git a/proprietary/src/main/java/stirling/software/proprietary/security/config/EnterpriseEndpointAspect.java b/proprietary/src/main/java/stirling/software/proprietary/security/config/EnterpriseEndpointAspect.java new file mode 100644 index 000000000..b0189f2bd --- /dev/null +++ b/proprietary/src/main/java/stirling/software/proprietary/security/config/EnterpriseEndpointAspect.java @@ -0,0 +1,30 @@ +package stirling.software.proprietary.security.config; + +import org.aspectj.lang.ProceedingJoinPoint; +import org.aspectj.lang.annotation.Around; +import org.aspectj.lang.annotation.Aspect; +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.http.HttpStatus; +import org.springframework.stereotype.Component; +import org.springframework.web.server.ResponseStatusException; + +@Aspect +@Component +public class EnterpriseEndpointAspect { + + private final boolean runningEE; + + public EnterpriseEndpointAspect(@Qualifier("runningEE") boolean runningEE) { + this.runningEE = runningEE; + } + + @Around( + "@annotation(stirling.software.proprietary.security.config.EnterpriseEndpoint) || @within(stirling.software.proprietary.security.config.EnterpriseEndpoint)") + public Object checkEnterpriseAccess(ProceedingJoinPoint joinPoint) throws Throwable { + if (!runningEE) { + throw new ResponseStatusException( + HttpStatus.FORBIDDEN, "This endpoint requires an Enterprise license"); + } + return joinPoint.proceed(); + } +} \ No newline at end of file diff --git a/proprietary/src/main/java/stirling/software/proprietary/service/AuditCleanupService.java b/proprietary/src/main/java/stirling/software/proprietary/service/AuditCleanupService.java new file mode 100644 index 000000000..442deecb1 --- /dev/null +++ b/proprietary/src/main/java/stirling/software/proprietary/service/AuditCleanupService.java @@ -0,0 +1,112 @@ +package stirling.software.proprietary.service; + +import java.time.Instant; +import java.time.temporal.ChronoUnit; +import java.util.List; +import java.util.concurrent.TimeUnit; + +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Sort; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import stirling.software.proprietary.config.AuditConfigurationProperties; +import stirling.software.proprietary.repository.PersistentAuditEventRepository; + +/** + * Service to periodically clean up old audit events based on retention policy. + */ +@Slf4j +@Service +@RequiredArgsConstructor +public class AuditCleanupService { + + private final PersistentAuditEventRepository auditRepository; + private final AuditConfigurationProperties auditConfig; + + // Default batch size for deletions + private static final int BATCH_SIZE = 10000; + + /** + * Scheduled task that runs daily to clean up old audit events. + * The retention period is configurable in settings.yml. + */ + @Scheduled(fixedDelay = 1, initialDelay = 1, timeUnit = TimeUnit.DAYS) + public void cleanupOldAuditEvents() { + if (!auditConfig.isEnabled()) { + return; + } + + int retentionDays = auditConfig.getRetentionDays(); + if (retentionDays <= 0) { + return; + } + + log.info("Starting audit cleanup for events older than {} days", retentionDays); + + try { + Instant cutoffDate = Instant.now().minus(retentionDays, ChronoUnit.DAYS); + int totalDeleted = batchDeleteEvents(cutoffDate); + log.info("Successfully cleaned up {} audit events older than {}", totalDeleted, cutoffDate); + } catch (Exception e) { + log.error("Error cleaning up old audit events", e); + } + } + + /** + * Performs batch deletion of events to prevent long-running transactions + * and potential database locks. + */ + private int batchDeleteEvents(Instant cutoffDate) { + int totalDeleted = 0; + boolean hasMore = true; + + while (hasMore) { + // Start a new transaction for each batch + List batchIds = findBatchOfIdsToDelete(cutoffDate); + + if (batchIds.isEmpty()) { + hasMore = false; + } else { + int deleted = deleteBatch(batchIds); + totalDeleted += deleted; + + // If we got fewer records than the batch size, we're done + if (batchIds.size() < BATCH_SIZE) { + hasMore = false; + } + } + } + + return totalDeleted; + } + + /** + * Finds a batch of IDs to delete. + */ + @Transactional(readOnly = true) + private List findBatchOfIdsToDelete(Instant cutoffDate) { + PageRequest pageRequest = PageRequest.of(0, BATCH_SIZE, Sort.by("id")); + return auditRepository.findIdsForBatchDeletion(cutoffDate, pageRequest); + } + + /** + * Deletes a batch of events by ID. + * Each batch is in its own transaction. + */ + @Transactional + private int deleteBatch(List batchIds) { + if (batchIds.isEmpty()) { + return 0; + } + + int batchSize = batchIds.size(); + auditRepository.deleteAllByIdInBatch(batchIds); + log.debug("Deleted batch of {} audit events", batchSize); + + return batchSize; + } +} \ No newline at end of file diff --git a/proprietary/src/main/java/stirling/software/proprietary/service/AuditService.java b/proprietary/src/main/java/stirling/software/proprietary/service/AuditService.java new file mode 100644 index 000000000..218969ffa --- /dev/null +++ b/proprietary/src/main/java/stirling/software/proprietary/service/AuditService.java @@ -0,0 +1,165 @@ +package stirling.software.proprietary.service; + +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.boot.actuate.audit.AuditEvent; +import org.springframework.boot.actuate.audit.AuditEventRepository; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.stereotype.Service; +import stirling.software.proprietary.audit.AuditEventType; +import stirling.software.proprietary.audit.AuditLevel; +import stirling.software.proprietary.config.AuditConfigurationProperties; + +import java.util.Map; + +/** + * Service for creating manual audit events throughout the application. + * This provides easy access to audit functionality in any component. + */ +@Slf4j +@Service +public class AuditService { + + private final AuditEventRepository repository; + private final AuditConfigurationProperties auditConfig; + private final boolean runningEE; + + public AuditService(AuditEventRepository repository, + AuditConfigurationProperties auditConfig, + @org.springframework.beans.factory.annotation.Qualifier("runningEE") boolean runningEE) { + this.repository = repository; + this.auditConfig = auditConfig; + this.runningEE = runningEE; + } + + /** + * Record an audit event for the current authenticated user with a specific audit level + * using the standardized AuditEventType enum + * + * @param type The event type from AuditEventType enum + * @param data Additional event data (will be automatically sanitized) + * @param level The minimum audit level required for this event to be logged + */ + public void audit(AuditEventType type, Map data, AuditLevel level) { + // Skip auditing if this level is not enabled or if not Enterprise edition + if (!auditConfig.isEnabled() || !auditConfig.getAuditLevel().includes(level) || !runningEE) { + return; + } + + String principal = getCurrentUsername(); + repository.add(new AuditEvent(principal, type.name(), data)); + } + + /** + * Record an audit event for the current authenticated user with standard level + * using the standardized AuditEventType enum + * + * @param type The event type from AuditEventType enum + * @param data Additional event data (will be automatically sanitized) + */ + public void audit(AuditEventType type, Map data) { + // Default to STANDARD level + audit(type, data, AuditLevel.STANDARD); + } + + /** + * Record an audit event for a specific user with a specific audit level + * using the standardized AuditEventType enum + * + * @param principal The username or system identifier + * @param type The event type from AuditEventType enum + * @param data Additional event data (will be automatically sanitized) + * @param level The minimum audit level required for this event to be logged + */ + public void audit(String principal, AuditEventType type, Map data, AuditLevel level) { + // Skip auditing if this level is not enabled or if not Enterprise edition + if (!auditConfig.isLevelEnabled(level) || !runningEE) { + return; + } + + repository.add(new AuditEvent(principal, type.name(), data)); + } + + /** + * Record an audit event for a specific user with standard level + * using the standardized AuditEventType enum + * + * @param principal The username or system identifier + * @param type The event type from AuditEventType enum + * @param data Additional event data (will be automatically sanitized) + */ + public void audit(String principal, AuditEventType type, Map data) { + // Default to STANDARD level + audit(principal, type, data, AuditLevel.STANDARD); + } + + /** + * Record an audit event for the current authenticated user with a specific audit level + * using a string-based event type (for backward compatibility) + * + * @param type The event type (e.g., "FILE_UPLOAD", "PASSWORD_CHANGE") + * @param data Additional event data (will be automatically sanitized) + * @param level The minimum audit level required for this event to be logged + */ + public void audit(String type, Map data, AuditLevel level) { + // Skip auditing if this level is not enabled or if not Enterprise edition + if (!auditConfig.isLevelEnabled(level) || !runningEE) { + return; + } + + String principal = getCurrentUsername(); + repository.add(new AuditEvent(principal, type, data)); + } + + /** + * Record an audit event for the current authenticated user with standard level + * using a string-based event type (for backward compatibility) + * + * @param type The event type (e.g., "FILE_UPLOAD", "PASSWORD_CHANGE") + * @param data Additional event data (will be automatically sanitized) + */ + public void audit(String type, Map data) { + // Default to STANDARD level + audit(type, data, AuditLevel.STANDARD); + } + + /** + * Record an audit event for a specific user with a specific audit level + * using a string-based event type (for backward compatibility) + * + * @param principal The username or system identifier + * @param type The event type (e.g., "FILE_UPLOAD", "PASSWORD_CHANGE") + * @param data Additional event data (will be automatically sanitized) + * @param level The minimum audit level required for this event to be logged + */ + public void audit(String principal, String type, Map data, AuditLevel level) { + // Skip auditing if this level is not enabled or if not Enterprise edition + if (!auditConfig.isLevelEnabled(level) || !runningEE) { + return; + } + + repository.add(new AuditEvent(principal, type, data)); + } + + /** + * Record an audit event for a specific user with standard level + * using a string-based event type (for backward compatibility) + * + * @param principal The username or system identifier + * @param type The event type (e.g., "FILE_UPLOAD", "PASSWORD_CHANGE") + * @param data Additional event data (will be automatically sanitized) + */ + public void audit(String principal, String type, Map data) { + // Default to STANDARD level + audit(principal, type, data, AuditLevel.STANDARD); + } + + /** + * Get the current authenticated username or "system" if none + */ + private String getCurrentUsername() { + Authentication auth = SecurityContextHolder.getContext().getAuthentication(); + return (auth != null && auth.getName() != null) ? auth.getName() : "system"; + } +} \ No newline at end of file diff --git a/proprietary/src/main/java/stirling/software/proprietary/util/SecretMasker.java b/proprietary/src/main/java/stirling/software/proprietary/util/SecretMasker.java new file mode 100644 index 000000000..82a0f7a52 --- /dev/null +++ b/proprietary/src/main/java/stirling/software/proprietary/util/SecretMasker.java @@ -0,0 +1,61 @@ +package stirling.software.proprietary.util; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.regex.Pattern; +import java.util.stream.Collectors; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.SerializationFeature; + +import lombok.extern.slf4j.Slf4j; + +/** Redacts any map values whose keys match common secret/token patterns. */ +@Slf4j +public final class SecretMasker { + + private static final Pattern SENSITIVE = + Pattern.compile("(?i)(password|token|secret|api[_-]?key|authorization|auth|jwt|cred|cert)"); + + private SecretMasker() {} + + public static Map mask(Map in) { + if (in == null) return null; + + return in.entrySet().stream() + .filter(e -> e.getValue() != null) + .collect(Collectors.toMap( + Map.Entry::getKey, + e -> deepMaskValue(e.getKey(), e.getValue()) + )); + } + + private static Object deepMask(Object value) { + if (value instanceof Map m) { + return m.entrySet().stream() + .filter(e -> e.getValue() != null) + .collect(Collectors.toMap( + Map.Entry::getKey, + e -> deepMaskValue((String)e.getKey(), e.getValue()) + )); + } else if (value instanceof List list) { + return list.stream() + .map(SecretMasker::deepMask).toList(); + } else { + return value; + } + } + + + private static Object deepMaskValue(String key, Object value) { + if (key != null && SENSITIVE.matcher(key).find()) { + return "***REDACTED***"; + } + return deepMask(value); + } + + + +} \ No newline at end of file diff --git a/proprietary/src/main/java/stirling/software/proprietary/web/AuditWebFilter.java b/proprietary/src/main/java/stirling/software/proprietary/web/AuditWebFilter.java new file mode 100644 index 000000000..a7dd6ea66 --- /dev/null +++ b/proprietary/src/main/java/stirling/software/proprietary/web/AuditWebFilter.java @@ -0,0 +1,97 @@ +package stirling.software.proprietary.web; + +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.slf4j.MDC; +import org.springframework.core.Ordered; +import org.springframework.core.annotation.Order; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.stereotype.Component; +import org.springframework.web.filter.OncePerRequestFilter; +import org.springframework.web.util.ContentCachingRequestWrapper; +import org.springframework.web.util.ContentCachingResponseWrapper; + +import java.io.IOException; +import java.util.Collections; +import java.util.Enumeration; +import java.util.HashMap; +import java.util.Map; + +/** + * Filter that stores additional request information for audit purposes + */ +@Slf4j +@Component +@Order(Ordered.HIGHEST_PRECEDENCE + 10) +@RequiredArgsConstructor +public class AuditWebFilter extends OncePerRequestFilter { + + private static final String USER_AGENT_HEADER = "User-Agent"; + private static final String REFERER_HEADER = "Referer"; + private static final String ACCEPT_LANGUAGE_HEADER = "Accept-Language"; + private static final String CONTENT_TYPE_HEADER = "Content-Type"; + + @Override + protected void doFilterInternal(HttpServletRequest request, + HttpServletResponse response, + FilterChain filterChain) throws ServletException, IOException { + + // Store key request info in MDC for logging and later audit use + try { + // Store request headers + String userAgent = request.getHeader(USER_AGENT_HEADER); + if (userAgent != null) { + MDC.put("userAgent", userAgent); + } + + String referer = request.getHeader(REFERER_HEADER); + if (referer != null) { + MDC.put("referer", referer); + } + + String acceptLanguage = request.getHeader(ACCEPT_LANGUAGE_HEADER); + if (acceptLanguage != null) { + MDC.put("acceptLanguage", acceptLanguage); + } + + String contentType = request.getHeader(CONTENT_TYPE_HEADER); + if (contentType != null) { + MDC.put("contentType", contentType); + } + + // Store authenticated user roles if available + Authentication auth = SecurityContextHolder.getContext().getAuthentication(); + if (auth != null && auth.getAuthorities() != null) { + String roles = auth.getAuthorities().stream() + .map(a -> a.getAuthority()) + .reduce((a, b) -> a + "," + b) + .orElse(""); + MDC.put("userRoles", roles); + } + + // Store query parameters (without values for privacy) + Map parameterMap = request.getParameterMap(); + if (parameterMap != null && !parameterMap.isEmpty()) { + String params = String.join(",", parameterMap.keySet()); + MDC.put("queryParams", params); + } + + // Continue with the filter chain + filterChain.doFilter(request, response); + + } finally { + // Clear MDC after request is processed + MDC.remove("userAgent"); + MDC.remove("referer"); + MDC.remove("acceptLanguage"); + MDC.remove("contentType"); + MDC.remove("userRoles"); + MDC.remove("queryParams"); + } + } +} \ No newline at end of file diff --git a/proprietary/src/main/java/stirling/software/proprietary/web/CorrelationIdFilter.java b/proprietary/src/main/java/stirling/software/proprietary/web/CorrelationIdFilter.java new file mode 100644 index 000000000..6357990a0 --- /dev/null +++ b/proprietary/src/main/java/stirling/software/proprietary/web/CorrelationIdFilter.java @@ -0,0 +1,47 @@ +package stirling.software.proprietary.web; + +import io.github.pixee.security.Newlines; +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.extern.slf4j.Slf4j; +import org.slf4j.MDC; +import org.springframework.stereotype.Component; +import org.springframework.util.StringUtils; +import org.springframework.web.filter.OncePerRequestFilter; + +import java.io.IOException; +import java.util.UUID; + +/** + * Guarantees every request carries a stable X-Request-Id; propagates to MDC. + */ +@Slf4j +@Component +public class CorrelationIdFilter extends OncePerRequestFilter { + + public static final String HEADER = "X-Request-Id"; + public static final String MDC_KEY = "requestId"; + + @Override + protected void doFilterInternal(HttpServletRequest req, + HttpServletResponse res, + FilterChain chain) + throws ServletException, IOException { + + try { + String id = req.getHeader(HEADER); + if (!StringUtils.hasText(id)) { + id = UUID.randomUUID().toString(); + } + req.setAttribute(MDC_KEY, id); + MDC.put(MDC_KEY, id); + res.setHeader(HEADER, Newlines.stripAll(id)); + + chain.doFilter(req, res); + } finally { + MDC.remove(MDC_KEY); + } + } +} diff --git a/proprietary/src/main/resources/application-proprietary.properties b/proprietary/src/main/resources/application-proprietary.properties new file mode 100644 index 000000000..e69de29bb diff --git a/proprietary/src/main/resources/static/css/audit-dashboard.css b/proprietary/src/main/resources/static/css/audit-dashboard.css new file mode 100644 index 000000000..51531c04e --- /dev/null +++ b/proprietary/src/main/resources/static/css/audit-dashboard.css @@ -0,0 +1,239 @@ +.dashboard-card { + margin-bottom: 20px; + border-radius: 8px; + box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1); + background-color: var(--md-sys-color-surface-container); + color: var(--md-sys-color-on-surface); + border: 1px solid var(--md-sys-color-outline-variant); +} + +.card-header { + background-color: var(--md-sys-color-surface-container-high); + color: var(--md-sys-color-on-surface); + border-bottom: 1px solid var(--md-sys-color-outline-variant); +} + +.card-body { + background-color: var(--md-sys-color-surface-container); +} +.stat-card { + text-align: center; + padding: 20px; +} +.stat-number { + font-size: 2rem; + font-weight: bold; +} +.stat-label { + font-size: 1rem; + color: var(--md-sys-color-on-surface-variant); +} +.chart-container { + position: relative; + height: 300px; + width: 100%; +} +.filter-card { + margin-bottom: 20px; + padding: 15px; + background-color: var(--md-sys-color-surface-container-low); + border: 1px solid var(--md-sys-color-outline-variant); + border-radius: 4px; +} +.loading-overlay { + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + background-color: var(--md-sys-color-surface-container-high, rgba(229, 232, 241, 0.8)); + display: flex; + justify-content: center; + align-items: center; + z-index: 1000; +} +.level-indicator { + display: inline-block; + padding: 5px 10px; + border-radius: 15px; + color: white; + font-weight: bold; +} +.level-0 { + background-color: var(--md-sys-color-error, #dc3545); /* Red */ +} +.level-1 { + background-color: var(--md-sys-color-secondary, #fd7e14); /* Orange */ +} +.level-2 { + background-color: var(--md-nav-section-color-other, #28a745); /* Green */ +} +.level-3 { + background-color: var(--md-sys-color-tertiary, #17a2b8); /* Teal */ +} +/* Custom data table styling */ +.audit-table { + font-size: 0.9rem; + color: var(--md-sys-color-on-surface); + border-color: var(--md-sys-color-outline-variant); +} + +.audit-table tbody tr { + background-color: var(--md-sys-color-surface-container-low); +} + +.audit-table tbody tr:nth-child(even) { + background-color: var(--md-sys-color-surface-container); +} + +.audit-table tbody tr:hover { + background-color: var(--md-sys-color-surface-container-high); +} +.audit-table th { + background-color: var(--md-sys-color-surface-container-high); + color: var(--md-sys-color-on-surface); + position: sticky; + top: 0; + z-index: 10; + font-weight: bold; +} +.table-responsive { + max-height: 600px; +} +.pagination-container { + display: flex; + justify-content: space-between; + align-items: center; + margin-top: 15px; + padding: 10px 0; + border-top: 1px solid var(--md-sys-color-outline-variant); + color: var(--md-sys-color-on-surface); +} + +.pagination .page-item.active .page-link { + background-color: var(--bs-primary); + border-color: var(--bs-primary); + color: white; +} + +.pagination .page-link { + color: var(--bs-primary); +} + +.pagination .page-link.disabled { + pointer-events: none; + color: var(--bs-secondary); + background-color: var(--bs-light); +} +.json-viewer { + background-color: var(--md-sys-color-surface-container-low); + color: var(--md-sys-color-on-surface); + border-radius: 4px; + padding: 15px; + max-height: 350px; + overflow-y: auto; + font-family: monospace; + font-size: 0.9rem; + white-space: pre-wrap; + border: 1px solid var(--md-sys-color-outline-variant); + margin-top: 10px; +} + +/* Simple, minimal radio styling - no extras */ +.form-check { + padding: 8px 0; +} + +#debug-console { + position: fixed; + bottom: 0; + right: 0; + width: 400px; + height: 200px; + background: var(--md-sys-color-surface-container-highest, rgba(0,0,0,0.8)); + color: var(--md-sys-color-tertiary, #0f0); + font-family: monospace; + font-size: 12px; + z-index: 9999; + overflow-y: auto; + padding: 10px; + border: 1px solid var(--md-sys-color-outline); + display: none; /* Changed to none by default, enable with key command */ +} + +/* Enhanced styling for radio buttons as buttons */ +label.btn-outline-primary { + cursor: pointer; + transition: all 0.2s; + border-color: var(--md-sys-color-primary); + color: var(--md-sys-color-primary); +} + +label.btn-outline-primary.active { + background-color: var(--md-sys-color-primary); + color: var(--md-sys-color-on-primary); + border-color: var(--md-sys-color-primary); +} + +label.btn-outline-primary input[type="radio"] { + cursor: pointer; +} + +/* Modal overrides for dark mode */ +.modal-content { + background-color: var(--md-sys-color-surface-container); + color: var(--md-sys-color-on-surface); + border-color: var(--md-sys-color-outline); +} + +.modal-header { + border-bottom-color: var(--md-sys-color-outline-variant); +} + +.modal-footer { + border-top-color: var(--md-sys-color-outline-variant); +} + +/* Improved modal positioning */ +.modal-dialog-centered { + display: flex; + align-items: center; + min-height: calc(100% - 3.5rem); +} + +.modal { + z-index: 1050; +} + +/* Button overrides for theme consistency */ +.btn-outline-primary { + color: var(--md-sys-color-primary); + border-color: var(--md-sys-color-primary); +} + +.btn-outline-primary:hover { + background-color: var(--md-sys-color-primary); + color: var(--md-sys-color-on-primary); +} + +.btn-outline-secondary { + color: var(--md-sys-color-secondary); + border-color: var(--md-sys-color-secondary); +} + +.btn-outline-secondary:hover { + background-color: var(--md-sys-color-secondary); + color: var(--md-sys-color-on-secondary); +} + +.btn-primary { + background-color: var(--md-sys-color-primary); + color: var(--md-sys-color-on-primary); + border-color: var(--md-sys-color-primary); +} + +.btn-secondary { + background-color: var(--md-sys-color-secondary); + color: var(--md-sys-color-on-secondary); + border-color: var(--md-sys-color-secondary); +} \ No newline at end of file diff --git a/proprietary/src/main/resources/static/js/audit/dashboard.js b/proprietary/src/main/resources/static/js/audit/dashboard.js new file mode 100644 index 000000000..3d013fa2e --- /dev/null +++ b/proprietary/src/main/resources/static/js/audit/dashboard.js @@ -0,0 +1,999 @@ +// Initialize variables +let currentPage = 0; +let pageSize = 20; +let totalPages = 0; +let typeFilter = ''; +let principalFilter = ''; +let startDateFilter = ''; +let endDateFilter = ''; + +// Charts +let typeChart; +let userChart; +let timeChart; + +// DOM elements - will properly initialize these during page load +let auditTableBody; +let pageSizeSelect; +let typeFilterInput; +let exportTypeFilterInput; +let principalFilterInput; +let startDateFilterInput; +let endDateFilterInput; +let applyFiltersButton; +let resetFiltersButton; + + +// Initialize page +// Theme change listener to redraw charts when theme changes +function setupThemeChangeListener() { + // Watch for theme changes (usually by a class on body or html element) + const observer = new MutationObserver(function(mutations) { + mutations.forEach(function(mutation) { + if (mutation.attributeName === 'data-bs-theme' || mutation.attributeName === 'class') { + // Redraw charts with new theme colors if they exist + if (typeChart && userChart && timeChart) { + // If we have stats data cached, use it + if (window.cachedStatsData) { + renderCharts(window.cachedStatsData); + } + } + } + }); + }); + + // Observe the document element for theme changes + observer.observe(document.documentElement, { attributes: true }); + + // Also observe body for class changes + observer.observe(document.body, { attributes: true }); +} + +document.addEventListener('DOMContentLoaded', function() { + // Initialize DOM references + auditTableBody = document.getElementById('auditTableBody'); + pageSizeSelect = document.getElementById('pageSizeSelect'); + typeFilterInput = document.getElementById('typeFilter'); + exportTypeFilterInput = document.getElementById('exportTypeFilter'); + principalFilterInput = document.getElementById('principalFilter'); + startDateFilterInput = document.getElementById('startDateFilter'); + endDateFilterInput = document.getElementById('endDateFilter'); + applyFiltersButton = document.getElementById('applyFilters'); + resetFiltersButton = document.getElementById('resetFilters'); + + // Load event types for dropdowns + loadEventTypes(); + + // Show a loading message immediately + if (auditTableBody) { + auditTableBody.innerHTML = + '
' + window.i18n.loading + ''; + } + + // Make a direct API call first to avoid validation issues + loadAuditData(0, pageSize); + + // Load statistics for dashboard + loadStats(7); + + // Setup theme change listener + setupThemeChangeListener(); + + // Set up event listeners + pageSizeSelect.addEventListener('change', function() { + pageSize = parseInt(this.value); + window.originalPageSize = pageSize; + currentPage = 0; + window.requestedPage = 0; + loadAuditData(0, pageSize); + }); + + applyFiltersButton.addEventListener('click', function() { + typeFilter = typeFilterInput.value.trim(); + principalFilter = principalFilterInput.value.trim(); + startDateFilter = startDateFilterInput.value; + endDateFilter = endDateFilterInput.value; + currentPage = 0; + window.requestedPage = 0; + loadAuditData(0, pageSize); + }); + + resetFiltersButton.addEventListener('click', function() { + // Reset input fields + typeFilterInput.value = ''; + principalFilterInput.value = ''; + startDateFilterInput.value = ''; + endDateFilterInput.value = ''; + + // Reset filter variables + typeFilter = ''; + principalFilter = ''; + startDateFilter = ''; + endDateFilter = ''; + + // Reset page + currentPage = 0; + window.requestedPage = 0; + + // Update UI + document.getElementById('currentPage').textContent = '1'; + + // Load data with reset filters + loadAuditData(0, pageSize); + }); + + // Reset export filters button + document.getElementById('resetExportFilters').addEventListener('click', function() { + exportTypeFilter.value = ''; + exportPrincipalFilter.value = ''; + exportStartDateFilter.value = ''; + exportEndDateFilter.value = ''; + }); + + // Make radio buttons behave like toggle buttons + const radioLabels = document.querySelectorAll('label.btn-outline-primary'); + radioLabels.forEach(label => { + const radio = label.querySelector('input[type="radio"]'); + + if (radio) { + // Highlight the checked radio button's label + if (radio.checked) { + label.classList.add('active'); + } + + // Handle clicking on the label + label.addEventListener('click', function() { + // Remove active class from all labels + radioLabels.forEach(l => l.classList.remove('active')); + + // Add active class to this label + this.classList.add('active'); + + // Check this radio button + radio.checked = true; + }); + } + }); + + // Handle export button + exportButton.onclick = function(e) { + e.preventDefault(); + + // Get selected format with fallback + const selectedRadio = document.querySelector('input[name="exportFormat"]:checked'); + const exportFormat = selectedRadio ? selectedRadio.value : 'csv'; + exportAuditData(exportFormat); + return false; + }; + + // Set up pagination buttons + document.getElementById('page-first').onclick = function() { + if (currentPage > 0) { + goToPage(0); + } + return false; + }; + + document.getElementById('page-prev').onclick = function() { + if (currentPage > 0) { + goToPage(currentPage - 1); + } + return false; + }; + + document.getElementById('page-next').onclick = function() { + if (currentPage < totalPages - 1) { + goToPage(currentPage + 1); + } + return false; + }; + + document.getElementById('page-last').onclick = function() { + if (totalPages > 0 && currentPage < totalPages - 1) { + goToPage(totalPages - 1); + } + return false; + }; + + // Set up tab change events + const tabEls = document.querySelectorAll('button[data-bs-toggle="tab"]'); + tabEls.forEach(tabEl => { + tabEl.addEventListener('shown.bs.tab', function (event) { + const targetId = event.target.getAttribute('data-bs-target'); + if (targetId === '#dashboard') { + // Redraw charts when dashboard tab is shown + if (typeChart) typeChart.update(); + if (userChart) userChart.update(); + if (timeChart) timeChart.update(); + } + }); + }); +}); + +// Load audit data from server +function loadAuditData(targetPage, realPageSize) { + const requestedPage = targetPage !== undefined ? targetPage : window.requestedPage || 0; + realPageSize = realPageSize || pageSize; + + showLoading('table-loading'); + + // Always request page 0 from server, but with increased page size if needed + let url = `/audit/data?page=${requestedPage}&size=${realPageSize}`; + + if (typeFilter) url += `&type=${encodeURIComponent(typeFilter)}`; + if (principalFilter) url += `&principal=${encodeURIComponent(principalFilter)}`; + if (startDateFilter) url += `&startDate=${startDateFilter}`; + if (endDateFilter) url += `&endDate=${endDateFilter}`; + + // Update page indicator + if (document.getElementById('page-indicator')) { + document.getElementById('page-indicator').textContent = `Page ${requestedPage + 1} of ?`; + } + + fetch(url) + .then(response => { + return response.json(); + }) + .then(data => { + + + // Calculate the correct slice of data to show for the requested page + let displayContent = data.content; + + // Render the correct slice of data + renderTable(displayContent); + + // Calculate total pages based on the actual total elements + const calculatedTotalPages = Math.ceil(data.totalElements / realPageSize); + totalPages = calculatedTotalPages; + currentPage = requestedPage; // Use our tracked page, not server's + + + // Update UI + document.getElementById('currentPage').textContent = currentPage + 1; + document.getElementById('totalPages').textContent = totalPages; + document.getElementById('totalRecords').textContent = data.totalElements; + if (document.getElementById('page-indicator')) { + document.getElementById('page-indicator').textContent = `Page ${currentPage + 1} of ${totalPages}`; + } + + // Re-enable buttons with correct state + document.getElementById('page-first').disabled = currentPage === 0; + document.getElementById('page-prev').disabled = currentPage === 0; + document.getElementById('page-next').disabled = currentPage >= totalPages - 1; + document.getElementById('page-last').disabled = currentPage >= totalPages - 1; + + hideLoading('table-loading'); + + // Restore original page size for next operations + if (window.originalPageSize && realPageSize !== window.originalPageSize) { + pageSize = window.originalPageSize; + + } + + // Store original page size for recovery + window.originalPageSize = realPageSize; + + // Clear busy flag + window.paginationBusy = false; + + }) + .catch(error => { + + if (auditTableBody) { + auditTableBody.innerHTML = `${window.i18n.errorLoading} ${error.message}`; + } + hideLoading('table-loading'); + + // Re-enable buttons + document.getElementById('page-first').disabled = false; + document.getElementById('page-prev').disabled = false; + document.getElementById('page-next').disabled = false; + document.getElementById('page-last').disabled = false; + + // Clear busy flag + window.paginationBusy = false; + }); +} + +// Load statistics for charts +function loadStats(days) { + showLoading('type-chart-loading'); + showLoading('user-chart-loading'); + showLoading('time-chart-loading'); + + fetch(`/audit/stats?days=${days}`) + .then(response => response.json()) + .then(data => { + document.getElementById('total-events').textContent = data.totalEvents; + // Cache stats data for theme changes + window.cachedStatsData = data; + renderCharts(data); + hideLoading('type-chart-loading'); + hideLoading('user-chart-loading'); + hideLoading('time-chart-loading'); + }) + .catch(error => { + console.error('Error loading stats:', error); + hideLoading('type-chart-loading'); + hideLoading('user-chart-loading'); + hideLoading('time-chart-loading'); + }); +} + +// Export audit data +function exportAuditData(format) { + const type = exportTypeFilter.value.trim(); + const principal = exportPrincipalFilter.value.trim(); + const startDate = exportStartDateFilter.value; + const endDate = exportEndDateFilter.value; + + let url = format === 'json' ? '/audit/export/json?' : '/audit/export?'; + + if (type) url += `&type=${encodeURIComponent(type)}`; + if (principal) url += `&principal=${encodeURIComponent(principal)}`; + if (startDate) url += `&startDate=${startDate}`; + if (endDate) url += `&endDate=${endDate}`; + + // Trigger download + window.location.href = url; +} + +// Render table with audit data +function renderTable(events) { + + if (!events || events.length === 0) { + auditTableBody.innerHTML = '' + window.i18n.noEventsFound + ''; + return; + } + + try { + auditTableBody.innerHTML = ''; + + events.forEach((event, index) => { + try { + const row = document.createElement('tr'); + row.innerHTML = ` + ${event.id || 'N/A'} + ${formatDate(event.timestamp)} + ${escapeHtml(event.principal || 'N/A')} + ${escapeHtml(event.type || 'N/A')} + + `; + + // Store event data for modal + row.dataset.event = JSON.stringify(event); + + // Add click handler for details button + const detailsButton = row.querySelector('.view-details'); + if (detailsButton) { + detailsButton.addEventListener('click', function() { + showEventDetails(event); + }); + } + + auditTableBody.appendChild(row); + } catch (rowError) { + + } + }); + + } catch (e) { + auditTableBody.innerHTML = '' + window.i18n.errorRendering + ' ' + e.message + ''; + } +} + +// Show event details in modal +function showEventDetails(event) { + // Get modal elements by ID with correct hyphenated IDs from HTML + const modalId = document.getElementById('modal-id'); + const modalPrincipal = document.getElementById('modal-principal'); + const modalType = document.getElementById('modal-type'); + const modalTimestamp = document.getElementById('modal-timestamp'); + const modalData = document.getElementById('modal-data'); + const eventDetailsModal = document.getElementById('eventDetailsModal'); + + // Set modal content + if (modalId) modalId.textContent = event.id; + if (modalPrincipal) modalPrincipal.textContent = event.principal; + if (modalType) modalType.textContent = event.type; + if (modalTimestamp) modalTimestamp.textContent = formatDate(event.timestamp); + + // Format JSON data + if (modalData) { + try { + const dataObj = typeof event.data === 'string' ? JSON.parse(event.data) : event.data; + modalData.textContent = JSON.stringify(dataObj, null, 2); + } catch (e) { + modalData.textContent = event.data || 'No data available'; + } + } + + // Show the modal + if (eventDetailsModal) { + const modal = new bootstrap.Modal(eventDetailsModal); + modal.show(); + } +} + +// No need for a dynamic pagination renderer anymore as we're using static buttons + +// Direct pagination approach - server seems to be hard-limited to returning 20 items +function goToPage(page) { + + // Basic validation - totalPages may not be initialized on first load + if (page < 0) { + return; + } + + // Skip validation against totalPages on first load + if (totalPages > 0 && page >= totalPages) { + return; + } + + // Simple guard flag + if (window.paginationBusy) { + return; + } + window.paginationBusy = true; + + try { + + // Store the requested page for later + window.requestedPage = page; + currentPage = page; + + // Update UI immediately for user feedback + document.getElementById('currentPage').textContent = page + 1; + + // Load data with this page + loadAuditData(page, pageSize); + } catch (e) { + window.paginationBusy = false; + } +} + +// Render charts +function renderCharts(data) { + // Get theme colors + const colors = getThemeColors(); + + // Prepare data for charts + const typeLabels = Object.keys(data.eventsByType); + const typeValues = Object.values(data.eventsByType); + + const userLabels = Object.keys(data.eventsByPrincipal); + const userValues = Object.values(data.eventsByPrincipal); + + // Sort days for time chart + const timeLabels = Object.keys(data.eventsByDay).sort(); + const timeValues = timeLabels.map(day => data.eventsByDay[day] || 0); + + // Chart.js global defaults for dark mode compatibility + Chart.defaults.color = colors.text; + Chart.defaults.borderColor = colors.grid; + + // Type chart + if (typeChart) { + typeChart.destroy(); + } + + const typeCtx = document.getElementById('typeChart').getContext('2d'); + typeChart = new Chart(typeCtx, { + type: 'bar', + data: { + labels: typeLabels, + datasets: [{ + label: window.i18n.eventsByType, + data: typeValues, + backgroundColor: colors.chartColors.slice(0, typeLabels.length).map(color => { + // Add transparency to the colors + if (color.startsWith('rgb(')) { + return color.replace('rgb(', 'rgba(').replace(')', ', 0.8)'); + } + return color; + }), + borderColor: colors.chartColors.slice(0, typeLabels.length), + borderWidth: 2, + borderRadius: 4 + }] + }, + options: { + responsive: true, + maintainAspectRatio: false, + plugins: { + legend: { + labels: { + color: colors.text, + font: { + weight: colors.isDarkMode ? 'bold' : 'normal', + size: 14 + }, + usePointStyle: true, + pointStyle: 'rectRounded', + boxWidth: 12, + boxHeight: 12, + } + }, + tooltip: { + titleFont: { + weight: 'bold', + size: 14 + }, + bodyFont: { + size: 13 + }, + backgroundColor: colors.isDarkMode ? 'rgba(40, 44, 52, 0.9)' : 'rgba(255, 255, 255, 0.9)', + titleColor: colors.isDarkMode ? '#ffffff' : '#000000', + bodyColor: colors.isDarkMode ? '#ffffff' : '#000000', + borderColor: colors.isDarkMode ? 'rgba(255, 255, 255, 0.5)' : colors.grid, + borderWidth: 1, + padding: 10, + cornerRadius: 6, + callbacks: { + label: function(context) { + return `${context.dataset.label}: ${context.raw}`; + } + } + } + }, + scales: { + y: { + beginAtZero: true, + ticks: { + color: colors.text, + font: { + weight: colors.isDarkMode ? 'bold' : 'normal', + size: 12 + }, + precision: 0 // Only show whole numbers + }, + grid: { + color: colors.isDarkMode ? 'rgba(255, 255, 255, 0.1)' : colors.grid + }, + title: { + display: true, + text: 'Count', + color: colors.text, + font: { + weight: colors.isDarkMode ? 'bold' : 'normal', + size: 14 + } + } + }, + x: { + ticks: { + color: colors.text, + font: { + weight: colors.isDarkMode ? 'bold' : 'normal', + size: 11 + }, + callback: function(value, index) { + // Get the original label + const label = this.getLabelForValue(value); + // If the label is too long, truncate it + const maxLength = 10; + if (label.length > maxLength) { + return label.substring(0, maxLength) + '...'; + } + return label; + }, + autoSkip: true, + maxRotation: 0, + minRotation: 0 + }, + grid: { + color: colors.isDarkMode ? 'rgba(255, 255, 255, 0.1)' : colors.grid, + display: false // Hide vertical gridlines for cleaner look + }, + title: { + display: true, + text: 'Event Type', + color: colors.text, + font: { + weight: colors.isDarkMode ? 'bold' : 'normal', + size: 14 + }, + padding: {top: 10, bottom: 0} + } + } + } + } + }); + + // User chart + if (userChart) { + userChart.destroy(); + } + + const userCtx = document.getElementById('userChart').getContext('2d'); + userChart = new Chart(userCtx, { + type: 'pie', + data: { + labels: userLabels, + datasets: [{ + label: window.i18n.eventsByUser, + data: userValues, + backgroundColor: colors.chartColors.slice(0, userLabels.length), + borderWidth: 2, + borderColor: colors.isDarkMode ? 'rgba(255, 255, 255, 0.8)' : 'rgba(0, 0, 0, 0.5)' + }] + }, + options: { + responsive: true, + maintainAspectRatio: false, + plugins: { + legend: { + position: 'right', + labels: { + color: colors.text, + font: { + size: colors.isDarkMode ? 14 : 12, + weight: colors.isDarkMode ? 'bold' : 'normal' + }, + padding: 15, + usePointStyle: true, + pointStyle: 'circle', + boxWidth: 10, + boxHeight: 10, + // Add a box around each label for better contrast in dark mode + generateLabels: function(chart) { + const original = Chart.overrides.pie.plugins.legend.labels.generateLabels; + const labels = original.call(this, chart); + + if (colors.isDarkMode) { + labels.forEach(label => { + // Enhance contrast for dark mode + label.fillStyle = label.fillStyle; // Keep original fill + label.strokeStyle = 'rgba(255, 255, 255, 0.8)'; // White border + label.lineWidth = 2; // Thicker border + }); + } + + return labels; + } + } + }, + tooltip: { + titleFont: { + weight: 'bold', + size: 14 + }, + bodyFont: { + size: 13 + }, + backgroundColor: colors.isDarkMode ? 'rgba(40, 44, 52, 0.9)' : 'rgba(255, 255, 255, 0.9)', + titleColor: colors.isDarkMode ? '#ffffff' : '#000000', + bodyColor: colors.isDarkMode ? '#ffffff' : '#000000', + borderColor: colors.isDarkMode ? 'rgba(255, 255, 255, 0.5)' : colors.grid, + borderWidth: 1, + padding: 10, + cornerRadius: 6 + } + } + } + }); + + // Time chart + if (timeChart) { + timeChart.destroy(); + } + + const timeCtx = document.getElementById('timeChart').getContext('2d'); + + // Get first color for line chart with appropriate transparency + let bgColor, borderColor; + if (colors.isDarkMode) { + bgColor = 'rgba(162, 201, 255, 0.3)'; // Light blue with transparency + borderColor = 'rgb(162, 201, 255)'; // Light blue solid + } else { + bgColor = 'rgba(0, 96, 170, 0.2)'; // Dark blue with transparency + borderColor = 'rgb(0, 96, 170)'; // Dark blue solid + } + + timeChart = new Chart(timeCtx, { + type: 'line', + data: { + labels: timeLabels, + datasets: [{ + label: window.i18n.eventsOverTime, + data: timeValues, + backgroundColor: bgColor, + borderColor: borderColor, + borderWidth: 3, + tension: 0.2, + fill: true, + pointBackgroundColor: borderColor, + pointBorderColor: colors.isDarkMode ? '#fff' : '#000', + pointBorderWidth: 2, + pointRadius: 5, + pointHoverRadius: 7 + }] + }, + options: { + responsive: true, + maintainAspectRatio: false, + plugins: { + legend: { + labels: { + color: colors.text, + font: { + weight: colors.isDarkMode ? 'bold' : 'normal', + size: 14 + }, + usePointStyle: true, + pointStyle: 'line', + boxWidth: 50, + boxHeight: 3 + } + }, + tooltip: { + titleFont: { + weight: 'bold', + size: 14 + }, + bodyFont: { + size: 13 + }, + backgroundColor: colors.isDarkMode ? 'rgba(40, 44, 52, 0.9)' : 'rgba(255, 255, 255, 0.9)', + titleColor: colors.isDarkMode ? '#ffffff' : '#000000', + bodyColor: colors.isDarkMode ? '#ffffff' : '#000000', + borderColor: colors.isDarkMode ? 'rgba(255, 255, 255, 0.5)' : colors.grid, + borderWidth: 1, + padding: 10, + cornerRadius: 6, + callbacks: { + label: function(context) { + return `Events: ${context.raw}`; + } + } + } + }, + interaction: { + intersect: false, + mode: 'index' + }, + scales: { + y: { + beginAtZero: true, + ticks: { + color: colors.text, + font: { + weight: colors.isDarkMode ? 'bold' : 'normal', + size: 12 + }, + precision: 0 // Only show whole numbers + }, + grid: { + color: colors.isDarkMode ? 'rgba(255, 255, 255, 0.1)' : colors.grid + }, + title: { + display: true, + text: 'Number of Events', + color: colors.text, + font: { + weight: colors.isDarkMode ? 'bold' : 'normal', + size: 14 + } + } + }, + x: { + ticks: { + color: colors.text, + font: { + weight: colors.isDarkMode ? 'bold' : 'normal', + size: 12 + }, + maxRotation: 45, + minRotation: 45 + }, + grid: { + color: colors.isDarkMode ? 'rgba(255, 255, 255, 0.1)' : colors.grid + }, + title: { + display: true, + text: 'Date', + color: colors.text, + font: { + weight: colors.isDarkMode ? 'bold' : 'normal', + size: 14 + }, + padding: {top: 20} + } + } + } + } + }); +} + +// Helper functions +function formatDate(timestamp) { + const date = new Date(timestamp); + return date.toLocaleString(); +} + +function escapeHtml(text) { + if (!text) return ''; + return text + .toString() + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/\"/g, '"') + .replace(/'/g, '''); +} + +function showLoading(id) { + const loading = document.getElementById(id); + if (loading) loading.style.display = 'flex'; +} + +function hideLoading(id) { + const loading = document.getElementById(id); + if (loading) loading.style.display = 'none'; +} + +// Load event types from the server for filter dropdowns +function loadEventTypes() { + fetch('/audit/types') + .then(response => response.json()) + .then(types => { + if (!types || types.length === 0) { + return; + } + + // Populate the type filter dropdowns + const typeFilter = document.getElementById('typeFilter'); + const exportTypeFilter = document.getElementById('exportTypeFilter'); + + // Clear existing options except the first one (All event types) + while (typeFilter.options.length > 1) { + typeFilter.remove(1); + } + + while (exportTypeFilter.options.length > 1) { + exportTypeFilter.remove(1); + } + + // Add new options + types.forEach(type => { + // Main filter dropdown + const option = document.createElement('option'); + option.value = type; + option.textContent = type; + typeFilter.appendChild(option); + + // Export filter dropdown + const exportOption = document.createElement('option'); + exportOption.value = type; + exportOption.textContent = type; + exportTypeFilter.appendChild(exportOption); + }); + }) + .catch(error => { + console.error('Error loading event types:', error); + }); +} + +// Get theme colors for charts +function getThemeColors() { + const isDarkMode = document.documentElement.getAttribute('data-bs-theme') === 'dark'; + + // In dark mode, use higher contrast colors for text + const textColor = isDarkMode ? + 'rgb(255, 255, 255)' : // White for dark mode for maximum contrast + getComputedStyle(document.documentElement).getPropertyValue('--md-sys-color-on-surface').trim(); + + // Use a more visible grid color in dark mode + const gridColor = isDarkMode ? + 'rgba(255, 255, 255, 0.2)' : // Semi-transparent white for dark mode + getComputedStyle(document.documentElement).getPropertyValue('--md-sys-color-outline-variant').trim(); + + // Define bright, high-contrast colors for both dark and light modes + const chartColorsDark = [ + 'rgb(162, 201, 255)', // Light blue - primary + 'rgb(193, 194, 248)', // Light purple - tertiary + 'rgb(255, 180, 171)', // Light red - error + 'rgb(72, 189, 84)', // Green - other + 'rgb(25, 177, 212)', // Cyan - convert + 'rgb(25, 101, 212)', // Blue - sign + 'rgb(255, 120, 146)', // Pink - security + 'rgb(104, 220, 149)', // Light green - convertto + 'rgb(212, 172, 25)', // Yellow - image + 'rgb(245, 84, 84)', // Red - advance + ]; + + const chartColorsLight = [ + 'rgb(0, 96, 170)', // Blue - primary + 'rgb(88, 90, 138)', // Purple - tertiary + 'rgb(186, 26, 26)', // Red - error + 'rgb(72, 189, 84)', // Green - other + 'rgb(25, 177, 212)', // Cyan - convert + 'rgb(25, 101, 212)', // Blue - sign + 'rgb(255, 120, 146)', // Pink - security + 'rgb(104, 220, 149)', // Light green - convertto + 'rgb(212, 172, 25)', // Yellow - image + 'rgb(245, 84, 84)', // Red - advance + ]; + + return { + text: textColor, + grid: gridColor, + backgroundColor: getComputedStyle(document.documentElement).getPropertyValue('--md-sys-color-surface-container').trim(), + chartColors: isDarkMode ? chartColorsDark : chartColorsLight, + isDarkMode: isDarkMode + }; +} + +// Function to generate a palette of colors for charts +function getChartColors(count, opacity = 0.6) { + try { + // Use theme colors first + const themeColors = getThemeColors(); + if (themeColors && themeColors.chartColors && themeColors.chartColors.length > 0) { + const result = []; + for (let i = 0; i < count; i++) { + // Get the raw color and add opacity + let color = themeColors.chartColors[i % themeColors.chartColors.length]; + // If it's rgb() format, convert to rgba() + if (color.startsWith('rgb(')) { + color = color.replace('rgb(', '').replace(')', ''); + result.push(`rgba(${color}, ${opacity})`); + } else { + // Just use the color directly + result.push(color); + } + } + return result; + } + } catch (e) { + console.warn('Error using theme colors, falling back to default colors', e); + } + + // Base colors - a larger palette than the default + const colors = [ + [54, 162, 235], // blue + [255, 99, 132], // red + [75, 192, 192], // teal + [255, 206, 86], // yellow + [153, 102, 255], // purple + [255, 159, 64], // orange + [46, 204, 113], // green + [231, 76, 60], // dark red + [52, 152, 219], // light blue + [155, 89, 182], // violet + [241, 196, 15], // dark yellow + [26, 188, 156], // turquoise + [230, 126, 34], // dark orange + [149, 165, 166], // light gray + [243, 156, 18], // amber + [39, 174, 96], // emerald + [211, 84, 0], // dark orange red + [22, 160, 133], // green sea + [41, 128, 185], // belize hole + [142, 68, 173] // wisteria + ]; + + const result = []; + + // Always use the same format regardless of color source + if (count > colors.length) { + // Generate colors algorithmically for large sets + for (let i = 0; i < count; i++) { + // Generate a color based on position in the hue circle (0-360) + const hue = (i * 360 / count) % 360; + const sat = 70 + Math.random() * 10; // 70-80% + const light = 50 + Math.random() * 10; // 50-60% + + result.push(`hsla(${hue}, ${sat}%, ${light}%, ${opacity})`); + } + } else { + // Use colors from our palette but also return in hsla format for consistency + for (let i = 0; i < count; i++) { + const color = colors[i % colors.length]; + result.push(`rgba(${color[0]}, ${color[1]}, ${color[2]}, ${opacity})`); + } + } + + return result; +} \ No newline at end of file diff --git a/proprietary/src/main/resources/templates/AUDIT_HELP.md b/proprietary/src/main/resources/templates/AUDIT_HELP.md new file mode 100644 index 000000000..be931076c --- /dev/null +++ b/proprietary/src/main/resources/templates/AUDIT_HELP.md @@ -0,0 +1,42 @@ +# Audit System Help + +## About the Audit System +The Stirling PDF audit system records user actions and system events for security monitoring, compliance, and troubleshooting purposes. + +## Audit Levels + +| Level | Name | Description | Use Case | +|-------|------|-------------|----------| +| 0 | OFF | Minimal auditing, only critical security events | Development environments | +| 1 | BASIC | Authentication events, security events, and errors | Production environments with minimal storage | +| 2 | STANDARD | All HTTP requests and operations (default) | Normal production use | +| 3 | VERBOSE | Detailed information including headers, parameters, and results | Troubleshooting and detailed analysis | + +## Configuration +Audit settings are configured in the `settings.yml` file under the `premium.proFeatures.audit` section: + +```yaml +premium: + proFeatures: + audit: + enabled: true # Enable/disable audit logging + level: 2 # Audit level (0=OFF, 1=BASIC, 2=STANDARD, 3=VERBOSE) + retentionDays: 90 # Number of days to retain audit logs +``` + +## Common Event Types + +### BASIC Events: +- USER_LOGIN - User login +- USER_LOGOUT - User logout +- USER_FAILED_LOGIN - Failed login attempt +- USER_PROFILE_UPDATE - User or profile operations + +### STANDARD Events: +- HTTP_REQUEST - GET requests for viewing +- PDF_PROCESS - PDF processing operations +- FILE_OPERATION - File-related operations +- SETTINGS_CHANGED - System or admin settings operations + +### VERBOSE Events: +- Detailed versions of STANDARD events with parameters and results \ No newline at end of file diff --git a/proprietary/src/main/resources/templates/AUDIT_USAGE.md b/proprietary/src/main/resources/templates/AUDIT_USAGE.md new file mode 100644 index 000000000..57cdce61b --- /dev/null +++ b/proprietary/src/main/resources/templates/AUDIT_USAGE.md @@ -0,0 +1,250 @@ +# Stirling PDF Audit System + +This document provides guidance on how to use the audit system in Stirling PDF. + +## Overview + +The audit system provides comprehensive logging of user actions and system events, storing them in a database for later review. This is useful for: + +- Security monitoring +- Compliance requirements +- User activity tracking +- Troubleshooting + +## Audit Levels + +The audit system supports different levels of detail that can be configured in the settings.yml file: + +### Level 0: OFF +- Disables all audit logging except for critical security events +- Minimal database usage and performance impact +- Only recommended for development environments + +### Level 1: BASIC +- Authentication events (login, logout, failed logins) +- Password changes +- User/role changes +- System configuration changes +- HTTP request errors (status codes >= 400) + +### Level 2: STANDARD (Default) +- Everything in BASIC plus: +- All HTTP requests (basic info: URL, method, status) +- File operations (upload, download, process) +- PDF operations (view, edit, etc.) +- User operations + +### Level 3: VERBOSE +- Everything in STANDARD plus: +- Request headers and parameters +- Method parameters +- Operation results +- Detailed timing information + +## Configuration + +Audit levels are configured in the settings.yml file under the premium section: + +```yaml +premium: + proFeatures: + audit: + enabled: true # Enable/disable audit logging + level: 2 # Audit level (0=OFF, 1=BASIC, 2=STANDARD, 3=VERBOSE) + retentionDays: 90 # Number of days to retain audit logs +``` + +## Automatic Auditing + +The following events are automatically audited (based on configured level): + +### HTTP Request Auditing +All HTTP requests are automatically audited with details based on the configured level: + +- **BASIC level**: Only errors (status code >= 400) +- **STANDARD level**: All requests with basic information (URL, method, status code, latency, IP) +- **VERBOSE level**: All of the above plus headers, parameters, and detailed timing + +### Controller Method Auditing +All controller methods with web mapping annotations are automatically audited: + +- `@GetMapping` +- `@PostMapping` +- `@PutMapping` +- `@DeleteMapping` +- `@PatchMapping` + +Methods with these annotations are audited at the **STANDARD** level by default. + +### Security Events +The following security events are always audited at the **BASIC** level: + +- Authentication events (login, logout, failed login attempts) +- Password changes +- User/role changes + +## Manual Auditing + +There are two ways to add audit events from your code: + +### 1. Using AuditService Directly + +Inject the `AuditService` and use it directly: + +```java +@Service +@RequiredArgsConstructor +public class MyService { + + private final AuditService auditService; + + public void processPdf(MultipartFile file) { + // Process the file... + + // Add an audit event with default level (STANDARD) + auditService.audit("PDF_PROCESSED", Map.of( + "filename", file.getOriginalFilename(), + "size", file.getSize(), + "operation", "process" + )); + + // Or specify an audit level + auditService.audit("PDF_PROCESSED_DETAILED", Map.of( + "filename", file.getOriginalFilename(), + "size", file.getSize(), + "operation", "process", + "metadata", file.getContentType(), + "user", "johndoe" + ), AuditLevel.VERBOSE); + + // Critical security events should use BASIC level to ensure they're always logged + auditService.audit("SECURITY_EVENT", Map.of( + "action", "file_access", + "resource", file.getOriginalFilename() + ), AuditLevel.BASIC); + } +} +``` + +### 2. Using the @Audited Annotation + +For simpler auditing, use the `@Audited` annotation on your methods: + +```java +@Service +public class UserService { + + // Basic audit level for important security events + @Audited(type = "USER_REGISTRATION", level = AuditLevel.BASIC) + public User registerUser(String username, String email) { + // Method implementation + User user = new User(username, email); + // Save user... + return user; + } + + // Sensitive operations should use BASIC but disable argument logging + @Audited(type = "USER_PASSWORD_CHANGE", level = AuditLevel.BASIC, includeArgs = false) + public void changePassword(String username, String newPassword) { + // Change password implementation + // includeArgs=false prevents the password from being included in the audit + } + + // Standard level for normal operations (default) + @Audited(type = "USER_LOGIN") + public boolean login(String username, String password) { + // Login implementation + return true; + } + + // Verbose level for detailed information + @Audited(type = "USER_SEARCH", level = AuditLevel.VERBOSE, includeResult = true) + public List searchUsers(String query) { + // Search implementation + // At VERBOSE level, this will include both the query and results + return userList; + } +} +``` + +With the `@Audited` annotation: +- You can specify the audit level using the `level` parameter +- Method arguments are automatically included in the audit event (unless `includeArgs = false`) +- Return values can be included with `includeResult = true` +- Exceptions are automatically captured and included in the audit +- The aspect handles all the boilerplate code for you +- The annotation respects the configured global audit level + +### 3. Controller Automatic Auditing + +In addition to the manual methods above, all controller methods with web mapping annotations are automatically audited, even without the `@Audited` annotation: + +```java +@RestController +@RequestMapping("/api/users") +public class UserController { + + // This method will be automatically audited + @GetMapping("/{id}") + public ResponseEntity getUser(@PathVariable String id) { + // Method implementation + return ResponseEntity.ok(user); + } + + // This method will be automatically audited + @PostMapping + public ResponseEntity createUser(@RequestBody User user) { + // Method implementation + return ResponseEntity.ok(savedUser); + } + + // This method uses @Audited and takes precedence over automatic auditing + @Audited(type = "USER_DELETE", level = AuditLevel.BASIC) + @DeleteMapping("/{id}") + public ResponseEntity deleteUser(@PathVariable String id) { + // Method implementation + return ResponseEntity.noContent().build(); + } +} +``` + +Important notes about automatic controller auditing: +- All controller methods with web mapping annotations are audited at the STANDARD level +- If a method already has an @Audited annotation, that takes precedence +- The audit event includes controller name, method name, path, and HTTP method +- At VERBOSE level, request parameters are also included +- Exceptions are automatically captured + +## Common Audit Event Types + +Use consistent event types throughout the application: + +- `FILE_UPLOAD` - When a file is uploaded +- `FILE_DOWNLOAD` - When a file is downloaded +- `PDF_PROCESS` - When a PDF is processed (split, merged, etc.) +- `USER_CREATE` - When a user is created +- `USER_UPDATE` - When a user details are updated +- `PASSWORD_CHANGE` - When a password is changed +- `PERMISSION_CHANGE` - When permissions are modified +- `SETTINGS_CHANGE` - When system settings are changed + +## Security Considerations + +- Sensitive data is automatically masked in audit logs (passwords, API keys, tokens) +- Each audit event includes a unique request ID for correlation +- Audit events are stored asynchronously to avoid performance impact +- The `/auditevents` endpoint is disabled to prevent unauthorized access to audit data + +## Database Storage + +Audit events are stored in the `audit_events` table with the following schema: + +- `id` - Unique identifier +- `principal` - The username or system identifier +- `type` - The event type +- `data` - JSON blob containing event details +- `timestamp` - When the event occurred + +## Metrics + +Prometheus metrics are available at `/actuator/prometheus` for monitoring system performance and audit event volume. \ No newline at end of file diff --git a/proprietary/src/main/resources/templates/audit/dashboard.html b/proprietary/src/main/resources/templates/audit/dashboard.html new file mode 100644 index 000000000..a0a61d69e --- /dev/null +++ b/proprietary/src/main/resources/templates/audit/dashboard.html @@ -0,0 +1,383 @@ + + + + + + + + + + + + +
+
+ + +
+

Audit Dashboard

+ + +
+
+

Audit System Status

+
+
+
+
+
+
Status
+
+ Enabled + Disabled +
+
+
+
+
+
Current Level
+
+ STANDARD +
+
+
+
+
+
Retention Period
+
90 days
+
+
+
+
+
Total Events
+
-
+
+
+
+
+
+ + + + +
+ +
+
+
+
+
+

Events by Type

+
+ + + +
+
+
+
+
+
+ Loading... +
+
+ +
+
+
+
+
+
+
+

Events by User

+
+
+
+
+
+ Loading... +
+
+ +
+
+
+
+
+
+
+
+
+

Events Over Time

+
+
+
+
+
+ Loading... +
+
+ +
+
+
+
+
+
+ + +
+
+
+

Audit Events

+
+
+ +
+
+
+
+ + +
+
+
+
+ + +
+
+
+
+ + +
+
+
+
+ + +
+
+
+
+
+ + +
+
+
+ + +
+
+
+ Loading... +
+
+ + + + + + + + + + + + + +
IDTimeUserTypeDetails
+
+ + +
+
+ Show + + entries + Page 1 of 1 (Total records: 0) +
+ +
+
+
+ + + +
+ + +
+
+
+

Export Audit Data

+
+
+ +
+
+
+
+ + +
+
+
+
+ + +
+
+
+
+ + +
+
+
+
+ + +
+
+
+
+
+
Export Format
+
+ + +
+
+
+ + +
+
+
+ +
+
Export Information
+

The export will include all audit events matching the selected filters. For large datasets, the export may take a few moments to generate.

+

Exported data will include:

+
    +
  • Event ID
  • +
  • User
  • +
  • Event Type
  • +
  • Timestamp
  • +
  • Event Data
  • +
+
+
+
+
+ +
+
+ + + + + + + + + + +
+
+ + + + \ No newline at end of file diff --git a/stirling-pdf/src/main/java/stirling/software/SPDF/SPDFApplication.java b/stirling-pdf/src/main/java/stirling/software/SPDF/SPDFApplication.java index cd356e8da..27bef32e4 100644 --- a/stirling-pdf/src/main/java/stirling/software/SPDF/SPDFApplication.java +++ b/stirling-pdf/src/main/java/stirling/software/SPDF/SPDFApplication.java @@ -175,7 +175,6 @@ public class SPDFApplication { } } } - log.info("Running configs {}", applicationProperties.toString()); } public static void setServerPortStatic(String port) { @@ -208,20 +207,19 @@ public class SPDFApplication { if (arg.startsWith("--spring.profiles.active=")) { String[] provided = arg.substring(arg.indexOf('=') + 1).split(","); if (provided.length > 0) { - log.info("#######0000000000000###############################"); return provided; } } } } - log.info("######################################"); + // 2. Detect if SecurityConfiguration is present on classpath if (isClassPresent( "stirling.software.proprietary.security.configuration.SecurityConfiguration")) { - log.info("security"); + log.info("Additional features in jar"); return new String[] {"security"}; } else { - log.info("default"); + log.info("Without additional features in jar"); return new String[] {"default"}; } } diff --git a/stirling-pdf/src/main/java/stirling/software/SPDF/config/CleanUrlInterceptor.java b/stirling-pdf/src/main/java/stirling/software/SPDF/config/CleanUrlInterceptor.java index cc9daff83..05057b609 100644 --- a/stirling-pdf/src/main/java/stirling/software/SPDF/config/CleanUrlInterceptor.java +++ b/stirling-pdf/src/main/java/stirling/software/SPDF/config/CleanUrlInterceptor.java @@ -13,17 +13,11 @@ import jakarta.servlet.http.HttpServletResponse; public class CleanUrlInterceptor implements HandlerInterceptor { - private static final List ALLOWED_PARAMS = - Arrays.asList( - "lang", - "endpoint", - "endpoints", - "logout", - "error", - "errorOAuth", - "file", - "messageType", - "infoMessage"); + private static final List ALLOWED_PARAMS = Arrays.asList( + "lang", "endpoint", "endpoints", "logout", "error", "errorOAuth", "file", "messageType", "infoMessage", + "page", "size", "type", "principal", "startDate", "endDate" + ); + @Override public boolean preHandle( diff --git a/stirling-pdf/src/main/resources/messages_en_GB.properties b/stirling-pdf/src/main/resources/messages_en_GB.properties index 121883405..22cbfaf17 100644 --- a/stirling-pdf/src/main/resources/messages_en_GB.properties +++ b/stirling-pdf/src/main/resources/messages_en_GB.properties @@ -1637,6 +1637,84 @@ validateSignature.cert.keyUsage=Key Usage validateSignature.cert.selfSigned=Self-Signed validateSignature.cert.bits=bits +# Audit Dashboard +audit.dashboard.title=Audit Dashboard +audit.dashboard.systemStatus=Audit System Status +audit.dashboard.status=Status +audit.dashboard.enabled=Enabled +audit.dashboard.disabled=Disabled +audit.dashboard.currentLevel=Current Level +audit.dashboard.retentionPeriod=Retention Period +audit.dashboard.days=days +audit.dashboard.totalEvents=Total Events + +# Audit Dashboard Tabs +audit.dashboard.tab.dashboard=Dashboard +audit.dashboard.tab.events=Audit Events +audit.dashboard.tab.export=Export +# Dashboard Charts +audit.dashboard.eventsByType=Events by Type +audit.dashboard.eventsByUser=Events by User +audit.dashboard.eventsOverTime=Events Over Time +audit.dashboard.period.7days=7 Days +audit.dashboard.period.30days=30 Days +audit.dashboard.period.90days=90 Days + +# Events Tab +audit.dashboard.auditEvents=Audit Events +audit.dashboard.filter.eventType=Event Type +audit.dashboard.filter.allEventTypes=All event types +audit.dashboard.filter.user=User +audit.dashboard.filter.userPlaceholder=Filter by user +audit.dashboard.filter.startDate=Start Date +audit.dashboard.filter.endDate=End Date +audit.dashboard.filter.apply=Apply Filters +audit.dashboard.filter.reset=Reset Filters + +# Table Headers +audit.dashboard.table.id=ID +audit.dashboard.table.time=Time +audit.dashboard.table.user=User +audit.dashboard.table.type=Type +audit.dashboard.table.details=Details +audit.dashboard.table.viewDetails=View Details + +# Pagination +audit.dashboard.pagination.show=Show +audit.dashboard.pagination.entries=entries +audit.dashboard.pagination.pageInfo1=Page +audit.dashboard.pagination.pageInfo2=of +audit.dashboard.pagination.totalRecords=Total records: + +# Modal +audit.dashboard.modal.eventDetails=Event Details +audit.dashboard.modal.id=ID +audit.dashboard.modal.user=User +audit.dashboard.modal.type=Type +audit.dashboard.modal.time=Time +audit.dashboard.modal.data=Data + +# Export Tab +audit.dashboard.export.title=Export Audit Data +audit.dashboard.export.format=Export Format +audit.dashboard.export.csv=CSV (Comma Separated Values) +audit.dashboard.export.json=JSON (JavaScript Object Notation) +audit.dashboard.export.button=Export Data +audit.dashboard.export.infoTitle=Export Information +audit.dashboard.export.infoDesc1=The export will include all audit events matching the selected filters. For large datasets, the export may take a few moments to generate. +audit.dashboard.export.infoDesc2=Exported data will include: +audit.dashboard.export.infoItem1=Event ID +audit.dashboard.export.infoItem2=User +audit.dashboard.export.infoItem3=Event Type +audit.dashboard.export.infoItem4=Timestamp +audit.dashboard.export.infoItem5=Event Data + +# JavaScript i18n keys +audit.dashboard.js.noEventsFound=No audit events found matching the current filters +audit.dashboard.js.errorLoading=Error loading data: +audit.dashboard.js.errorRendering=Error rendering table: +audit.dashboard.js.loadingPage=Loading page + #################### # Cookie banner # #################### diff --git a/stirling-pdf/src/main/resources/settings.yml.template b/stirling-pdf/src/main/resources/settings.yml.template index e786b9080..d651eff9f 100644 --- a/stirling-pdf/src/main/resources/settings.yml.template +++ b/stirling-pdf/src/main/resources/settings.yml.template @@ -76,6 +76,11 @@ premium: clientId: '' apiKey: '' appId: '' + enterpriseFeatures: + audit: + enabled: true # Enable audit logging + level: 2 # Audit logging level: 0=OFF, 1=BASIC, 2=STANDARD, 3=VERBOSE + retentionDays: 90 # Number of days to retain audit logs mail: enabled: false # set to 'true' to enable sending emails diff --git a/stirling-pdf/src/main/resources/templates/adminSettings.html b/stirling-pdf/src/main/resources/templates/adminSettings.html index a13837d00..0d14525c1 100644 --- a/stirling-pdf/src/main/resources/templates/adminSettings.html +++ b/stirling-pdf/src/main/resources/templates/adminSettings.html @@ -112,6 +112,11 @@ analytics Usage Statistics + + + security + Audit Dashboard +