mirror of
				https://github.com/Frooodle/Stirling-PDF.git
				synced 2025-10-25 11:17:28 +02:00 
			
		
		
		
	Auditing support (#3739)
# Description of Changes This pull request introduces a comprehensive auditing system to the application, along with minor updates to existing utilities and dependencies. The most significant changes include the addition of audit-related classes and enums, updates to the `ApplicationProperties` model to support auditing configuration, and enhancements to utility methods for handling static and trackable resources. ### Audit System Implementation: * **Audit Aspect for Method Annotations**: Added `AuditAspect` to process the new `@Audited` annotation, enabling detailed logging of method execution, HTTP requests, and operation results based on configurable audit levels. (`proprietary/src/main/java/stirling/software/proprietary/audit/AuditAspect.java`) * **Audit Event Types**: Introduced `AuditEventType` enum to define standardized event types for auditing, such as authentication events, file operations, and HTTP requests. (`proprietary/src/main/java/stirling/software/proprietary/audit/AuditEventType.java`) * **Audit Levels**: Added `AuditLevel` enum to define different levels of audit logging (OFF, BASIC, STANDARD, VERBOSE), providing granular control over the amount of data logged. (`proprietary/src/main/java/stirling/software/proprietary/audit/AuditLevel.java`) ### Application Properties Update: * **Audit Configuration in `ProFeatures`**: Updated the `ProFeatures` class in `ApplicationProperties` to include support for auditing with configurable retention days, levels, and enablement flags. (`common/src/main/java/stirling/software/common/model/ApplicationProperties.java`) ### Utility Enhancements: * **Static and Trackable Resource Handling**: Extended `RequestUriUtils` methods (`isStaticResource` and `isTrackableResource`) to recognize `.txt` files as valid static and trackable resources. (`common/src/main/java/stirling/software/common/util/RequestUriUtils.java`) [[1]](diffhunk://#diff-de3599037908683f2cd8f170939547612c6fc2203e9207eb4d7966508f92bbcbR22) [[2]](diffhunk://#diff-de3599037908683f2cd8f170939547612c6fc2203e9207eb4d7966508f92bbcbR39) ### Dependency Update: * **Spring Validation Starter**: Added `spring-boot-starter-validation` to project dependencies to support validation mechanisms required for auditing features. (`proprietary/build.gradle`) Dashboard WIP    --- ## Checklist ### General - [ ] I have read the [Contribution Guidelines](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/CONTRIBUTING.md) - [ ] I have read the [Stirling-PDF Developer Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/DeveloperGuide.md) (if applicable) - [ ] I have read the [How to add new languages to Stirling-PDF](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/HowToAddNewLanguage.md) (if applicable) - [ ] I have performed a self-review of my own code - [ ] My changes generate no new warnings ### Documentation - [ ] I have updated relevant docs on [Stirling-PDF's doc repo](https://github.com/Stirling-Tools/Stirling-Tools.github.io/blob/main/docs/) (if functionality has heavily changed) - [ ] I have read the section [Add New Translation Tags](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/HowToAddNewLanguage.md#add-new-translation-tags) (for new translation tags only) ### UI Changes (if applicable) - [ ] Screenshots or videos demonstrating the UI changes are attached (e.g., as comments or direct attachments in the PR) ### Testing (if applicable) - [ ] I have tested my changes locally. Refer to the [Testing Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/DeveloperGuide.md#6-testing) for more details. --------- Co-authored-by: a <a> Co-authored-by: pixeebot[bot] <104101892+pixeebot[bot]@users.noreply.github.com>
This commit is contained in:
		
							parent
							
								
									ee41dc11c2
								
							
						
					
					
						commit
						552f2ced4d
					
				| @ -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; | ||||
|  | ||||
| @ -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") | ||||
|  | ||||
| @ -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<String, Object> auditData = AuditUtils.createBaseAuditData(joinPoint, auditedAnnotation.level()); | ||||
|          | ||||
|         // Add HTTP information if we're in a web context | ||||
|         ServletRequestAttributes attrs = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes(); | ||||
|         if (attrs != null) { | ||||
|             HttpServletRequest req = attrs.getRequest(); | ||||
|             String path = req.getRequestURI(); | ||||
|             String httpMethod = req.getMethod(); | ||||
|             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()); | ||||
|             } | ||||
|         } | ||||
|     } | ||||
| } | ||||
| @ -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; | ||||
|         } | ||||
|     } | ||||
| } | ||||
| @ -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; | ||||
|     } | ||||
| } | ||||
| @ -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<String, Object> createBaseAuditData(ProceedingJoinPoint joinPoint, AuditLevel auditLevel) { | ||||
|         Map<String, Object> data = new HashMap<>(); | ||||
|          | ||||
|         // Common data for all levels | ||||
|         data.put("timestamp", Instant.now().toString()); | ||||
|          | ||||
|         // Add principal if available | ||||
|         Authentication auth = SecurityContextHolder.getContext().getAuthentication(); | ||||
|         if (auth != null && auth.getName() != null) { | ||||
|             data.put("principal", auth.getName()); | ||||
|         } else { | ||||
|             data.put("principal", "system"); | ||||
|         } | ||||
|          | ||||
|         // Add class name and method name only at VERBOSE level | ||||
|         if (auditLevel.includes(AuditLevel.VERBOSE)) { | ||||
|             data.put("className", joinPoint.getTarget().getClass().getName()); | ||||
|             data.put("methodName", ((MethodSignature) joinPoint.getSignature()).getMethod().getName()); | ||||
|         } | ||||
|          | ||||
|         return data; | ||||
|     } | ||||
|      | ||||
|     /** | ||||
|      * Add HTTP-specific information to the audit data if available | ||||
|      * | ||||
|      * @param data The existing audit data map | ||||
|      * @param httpMethod The HTTP method (GET, POST, etc.) | ||||
|      * @param path The request path | ||||
|      * @param auditLevel The current audit level | ||||
|      */ | ||||
|     public static void addHttpData(Map<String, Object> data, String httpMethod, String path, AuditLevel auditLevel) { | ||||
|         if (httpMethod == null || path == null) { | ||||
|             return; // Skip if we don't have basic HTTP info | ||||
|         } | ||||
|          | ||||
|         // BASIC level HTTP data | ||||
|         data.put("httpMethod", httpMethod); | ||||
|         data.put("path", path); | ||||
|          | ||||
|         // Get request attributes safely | ||||
|         ServletRequestAttributes attrs = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes(); | ||||
|         if (attrs == null) { | ||||
|             return; // No request context available | ||||
|         } | ||||
|          | ||||
|         HttpServletRequest req = attrs.getRequest(); | ||||
|         if (req == null) { | ||||
|             return; // No request available | ||||
|         } | ||||
|          | ||||
|         // STANDARD level HTTP data | ||||
|         if (auditLevel.includes(AuditLevel.STANDARD)) { | ||||
|             data.put("clientIp", req.getRemoteAddr()); | ||||
|             data.put("sessionId", req.getSession(false) != null ? req.getSession(false).getId() : null); | ||||
|             data.put("requestId", MDC.get("requestId")); | ||||
|              | ||||
|             // Form data for POST/PUT/PATCH | ||||
|             if (("POST".equalsIgnoreCase(httpMethod) ||  | ||||
|                  "PUT".equalsIgnoreCase(httpMethod) ||  | ||||
|                  "PATCH".equalsIgnoreCase(httpMethod)) && req.getContentType() != null) { | ||||
|                  | ||||
|                 String contentType = req.getContentType(); | ||||
|                 if (contentType.contains("application/x-www-form-urlencoded") || | ||||
|                     contentType.contains("multipart/form-data")) { | ||||
|                      | ||||
|                     Map<String, String[]> params = new HashMap<>(req.getParameterMap()); | ||||
|                     // Remove CSRF token from logged parameters | ||||
|                     params.remove("_csrf"); | ||||
|                      | ||||
|                     if (!params.isEmpty()) { | ||||
|                         data.put("formParams", params); | ||||
|                     } | ||||
|                 } | ||||
|             } | ||||
|         } | ||||
|     } | ||||
|      | ||||
|     /** | ||||
|      * Add file information to the audit data if available | ||||
|      * | ||||
|      * @param data The existing audit data map | ||||
|      * @param joinPoint The AspectJ join point | ||||
|      * @param auditLevel The current audit level | ||||
|      */ | ||||
|     public static void addFileData(Map<String, Object> data, ProceedingJoinPoint joinPoint, AuditLevel auditLevel) { | ||||
|         if (auditLevel.includes(AuditLevel.STANDARD)) { | ||||
|             List<MultipartFile> files = Arrays.stream(joinPoint.getArgs()) | ||||
|                     .filter(a -> a instanceof MultipartFile) | ||||
|                     .map(a -> (MultipartFile)a) | ||||
|                     .collect(Collectors.toList()); | ||||
| 
 | ||||
|             if (!files.isEmpty()) { | ||||
|                 List<Map<String,Object>> fileInfos = files.stream().map(f -> { | ||||
|                     Map<String,Object> m = new HashMap<>(); | ||||
|                     m.put("name", f.getOriginalFilename()); | ||||
|                     m.put("size", f.getSize()); | ||||
|                     m.put("type", f.getContentType()); | ||||
|                     return m; | ||||
|                 }).collect(Collectors.toList()); | ||||
| 
 | ||||
|                 data.put("files", fileInfos); | ||||
|             } | ||||
|         } | ||||
|     } | ||||
|      | ||||
|     /** | ||||
|      * Add method arguments to the audit data | ||||
|      * | ||||
|      * @param data The existing audit data map | ||||
|      * @param joinPoint The AspectJ join point | ||||
|      * @param auditLevel The current audit level | ||||
|      */ | ||||
|     public static void addMethodArguments(Map<String, Object> data, ProceedingJoinPoint joinPoint, AuditLevel auditLevel) { | ||||
|         if (auditLevel.includes(AuditLevel.VERBOSE)) { | ||||
|             MethodSignature sig = (MethodSignature) joinPoint.getSignature(); | ||||
|             String[] names = sig.getParameterNames(); | ||||
|             Object[] vals = joinPoint.getArgs(); | ||||
|             if (names != null && vals != null) { | ||||
|                 IntStream.range(0, names.length) | ||||
|                         .forEach(i -> { | ||||
|                             if (vals[i] != null) { | ||||
|                                 // Convert objects to safe string representation | ||||
|                                 data.put("arg_" + names[i], safeToString(vals[i], 500)); | ||||
|                             } else { | ||||
|                                 data.put("arg_" + names[i], null); | ||||
|                             } | ||||
|                         }); | ||||
|             } | ||||
|         } | ||||
|     } | ||||
|      | ||||
|     /** | ||||
|      * Safely convert an object to string with size limiting | ||||
|      *  | ||||
|      * @param obj The object to convert | ||||
|      * @param maxLength Maximum length of the resulting string | ||||
|      * @return A safe string representation, truncated if needed | ||||
|      */ | ||||
|     public static String safeToString(Object obj, int maxLength) { | ||||
|         if (obj == null) { | ||||
|             return "null"; | ||||
|         } | ||||
|          | ||||
|         String result; | ||||
|         try { | ||||
|             // Handle common types directly to avoid toString() overhead | ||||
|             if (obj instanceof String) { | ||||
|                 result = (String) obj; | ||||
|             } else if (obj instanceof Number || obj instanceof Boolean) { | ||||
|                 result = obj.toString(); | ||||
|             } else if (obj instanceof byte[]) { | ||||
|                 result = "[binary data length=" + ((byte[]) obj).length + "]"; | ||||
|             } else { | ||||
|                 // For complex objects, use toString but handle exceptions | ||||
|                 result = obj.toString(); | ||||
|             } | ||||
|              | ||||
|             // Truncate if necessary | ||||
|             if (result != null && result.length() > maxLength) { | ||||
|                 return StringUtils.truncate(result, maxLength - 3) + "..."; | ||||
|             } | ||||
|              | ||||
|             return result; | ||||
|         } catch (Exception e) { | ||||
|             // If toString() fails, return the class name | ||||
|             return "[" + obj.getClass().getName() + " - toString() failed]"; | ||||
|         } | ||||
|     } | ||||
|      | ||||
|     /** | ||||
|      * Determine if a method should be audited based on config and annotation | ||||
|      * | ||||
|      * @param method The method to check | ||||
|      * @param auditConfig The audit configuration | ||||
|      * @return true if the method should be audited | ||||
|      */ | ||||
|     public static boolean shouldAudit(Method method, AuditConfigurationProperties auditConfig) { | ||||
|         // First check if audit is globally enabled - fast path | ||||
|         if (!auditConfig.isEnabled()) { | ||||
|             return false; | ||||
|         } | ||||
|          | ||||
|         // Check for annotation override | ||||
|         Audited auditedAnnotation = method.getAnnotation(Audited.class); | ||||
|         AuditLevel requiredLevel = (auditedAnnotation != null)  | ||||
|             ? auditedAnnotation.level()  | ||||
|             : AuditLevel.BASIC; | ||||
|              | ||||
|         // Check if the required level is enabled | ||||
|         return auditConfig.getAuditLevel().includes(requiredLevel); | ||||
|     } | ||||
|      | ||||
|     /** | ||||
|      * Add timing and response status data to the audit record | ||||
|      * | ||||
|      * @param data The audit data to add to | ||||
|      * @param startTime The start time in milliseconds | ||||
|      * @param response The HTTP response (may be null for non-HTTP methods) | ||||
|      * @param level The current audit level | ||||
|      * @param isHttpRequest Whether this is an HTTP request (controller) or a regular method call | ||||
|      */ | ||||
|     public static void addTimingData(Map<String, Object> data, long startTime, HttpServletResponse response, AuditLevel level, boolean isHttpRequest) { | ||||
|         if (level.includes(AuditLevel.STANDARD)) { | ||||
|             // For HTTP requests, let ControllerAuditAspect handle timing separately | ||||
|             // For non-HTTP methods, add execution time here | ||||
|             if (!isHttpRequest) { | ||||
|                 data.put("latencyMs", System.currentTimeMillis() - startTime); | ||||
|             } | ||||
|              | ||||
|             // Add HTTP status code if available | ||||
|             if (response != null) { | ||||
|                 try { | ||||
|                     data.put("statusCode", response.getStatus()); | ||||
|                 } catch (Exception e) { | ||||
|                     // Ignore - response might be in an inconsistent state | ||||
|                 } | ||||
|             } | ||||
|         } | ||||
|     } | ||||
|      | ||||
|     /** | ||||
|      * Resolve the event type to use for auditing, considering annotations and context | ||||
|      * | ||||
|      * @param method The method being audited | ||||
|      * @param controller The controller class | ||||
|      * @param path The request path (may be null for non-HTTP methods) | ||||
|      * @param httpMethod The HTTP method (may be null for non-HTTP methods) | ||||
|      * @param annotation The @Audited annotation (may be null) | ||||
|      * @return The resolved event type (never null) | ||||
|      */ | ||||
|     public static AuditEventType resolveEventType(Method method, Class<?> controller, String path, String httpMethod, Audited annotation) { | ||||
|         // First check if we have an explicit annotation | ||||
|         if (annotation != null && annotation.type() != AuditEventType.HTTP_REQUEST) { | ||||
|             return annotation.type(); | ||||
|         } | ||||
|          | ||||
|         // For HTTP methods, infer based on controller and path | ||||
|         if (httpMethod != null && path != null) { | ||||
|             String cls = controller.getSimpleName().toLowerCase(); | ||||
|             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()); | ||||
|     } | ||||
| } | ||||
| @ -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: | ||||
|  *  | ||||
|  * <pre> | ||||
|  * {@code  | ||||
|  * @Audited(type = AuditEventType.USER_REGISTRATION, level = AuditLevel.BASIC) | ||||
|  * public void registerUser(String username) { | ||||
|  *    // Method implementation | ||||
|  * } | ||||
|  * } | ||||
|  * </pre> | ||||
|  *  | ||||
|  * For backward compatibility, string-based event types are still supported: | ||||
|  *  | ||||
|  * <pre> | ||||
|  * {@code  | ||||
|  * @Audited(typeString = "CUSTOM_EVENT_TYPE", level = AuditLevel.BASIC) | ||||
|  * public void customOperation() { | ||||
|  *    // Method implementation | ||||
|  * } | ||||
|  * } | ||||
|  * </pre> | ||||
|  */ | ||||
| @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; | ||||
| } | ||||
| @ -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<String, Object> data = AuditUtils.createBaseAuditData(joinPoint, level); | ||||
|          | ||||
|         // Add HTTP-specific information | ||||
|         AuditUtils.addHttpData(data, httpMethod, path, level); | ||||
|          | ||||
|         // Add file information if present | ||||
|         AuditUtils.addFileData(data, joinPoint, level); | ||||
|          | ||||
|         // Add method arguments if at VERBOSE level | ||||
|         if (level.includes(AuditLevel.VERBOSE)) { | ||||
|             AuditUtils.addMethodArguments(data, joinPoint, level); | ||||
|         } | ||||
| 
 | ||||
|         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 | ||||
| } | ||||
| @ -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<String, String> 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; | ||||
|     } | ||||
| } | ||||
| @ -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; | ||||
|     } | ||||
| } | ||||
| @ -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 | ||||
| } | ||||
| @ -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<AuditEvent> find(String p, Instant after, String type) { | ||||
|         return List.of(); | ||||
|     } | ||||
| 
 | ||||
|     /* ── WRITE side (async) ───────────────────────────────── */ | ||||
|     @Async("auditExecutor") | ||||
|     @Override | ||||
|     public void add(AuditEvent ev) { | ||||
|         try { | ||||
|             Map<String, Object> 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 | ||||
|         } | ||||
|     } | ||||
| } | ||||
| @ -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<String, Object> 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<PersistentAuditEvent> 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<PersistentAuditEvent> content = events.getContent(); | ||||
| 
 | ||||
|         Map<String, Object> 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<String, Object> 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<PersistentAuditEvent> events = auditRepository.findByTimestampAfter(startDate); | ||||
|          | ||||
|         // Count events by type | ||||
|         Map<String, Long> eventsByType = events.stream() | ||||
|                 .collect(Collectors.groupingBy(PersistentAuditEvent::getType, Collectors.counting())); | ||||
|          | ||||
|         // Count events by principal | ||||
|         Map<String, Long> eventsByPrincipal = events.stream() | ||||
|                 .collect(Collectors.groupingBy(PersistentAuditEvent::getPrincipal, Collectors.counting())); | ||||
|          | ||||
|         // Count events by day | ||||
|         Map<String, Long> eventsByDay = events.stream() | ||||
|                 .collect(Collectors.groupingBy( | ||||
|                         e -> LocalDateTime.ofInstant(e.getTimestamp(), ZoneId.systemDefault()) | ||||
|                                 .format(DateTimeFormatter.ISO_LOCAL_DATE), | ||||
|                         Collectors.counting())); | ||||
|          | ||||
|         Map<String, Object> 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<String> getAuditTypes() { | ||||
|         // Get distinct event types from the database | ||||
|         List<String> dbTypes = auditRepository.findDistinctEventTypes(); | ||||
|          | ||||
|         // Include standard enum types in case they're not in the database yet | ||||
|         List<String> enumTypes = Arrays.stream(AuditEventType.values()) | ||||
|                 .map(AuditEventType::name) | ||||
|                 .collect(Collectors.toList()); | ||||
|          | ||||
|         // Combine both sources, remove duplicates, and sort | ||||
|         Set<String> 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<byte[]> 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<PersistentAuditEvent> 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<byte[]> 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<PersistentAuditEvent> 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("\"", "\"\"") + "\""; | ||||
|     } | ||||
| } | ||||
| @ -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; | ||||
| } | ||||
| @ -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<PersistentAuditEvent, Long> { | ||||
|      | ||||
|     // Basic queries | ||||
|     @Query("SELECT e FROM PersistentAuditEvent e WHERE UPPER(e.principal) LIKE UPPER(CONCAT('%', :principal, '%'))") | ||||
|     Page<PersistentAuditEvent> findByPrincipal(@Param("principal") String principal, Pageable pageable); | ||||
|     Page<PersistentAuditEvent> findByType(String type, Pageable pageable); | ||||
|     Page<PersistentAuditEvent> 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<PersistentAuditEvent> 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<PersistentAuditEvent> findByPrincipalAndTimestampBetween(@Param("principal") String principal, @Param("startDate") Instant startDate, @Param("endDate") Instant endDate, Pageable pageable); | ||||
|     Page<PersistentAuditEvent> 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<PersistentAuditEvent> 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<PersistentAuditEvent> findAllByPrincipalForExport(@Param("principal") String principal); | ||||
|     @Query("SELECT e FROM PersistentAuditEvent e WHERE e.type = :type") | ||||
|     List<PersistentAuditEvent> findByTypeForExport(@Param("type") String type); | ||||
|     @Query("SELECT e FROM PersistentAuditEvent e WHERE e.timestamp BETWEEN :startDate AND :endDate") | ||||
|     List<PersistentAuditEvent> findAllByTimestampBetweenForExport(@Param("startDate") Instant startDate, @Param("endDate") Instant endDate); | ||||
|     @Query("SELECT e FROM PersistentAuditEvent e WHERE e.timestamp > :startDate") | ||||
|     List<PersistentAuditEvent> findByTimestampAfter(@Param("startDate") Instant startDate); | ||||
|     @Query("SELECT e FROM PersistentAuditEvent e WHERE UPPER(e.principal) LIKE UPPER(CONCAT('%', :principal, '%')) AND e.type = :type") | ||||
|     List<PersistentAuditEvent> 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<PersistentAuditEvent> 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<PersistentAuditEvent> 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<PersistentAuditEvent> 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<Long> findIdsForBatchDeletion(Instant cutoffDate, Pageable pageable); | ||||
|      | ||||
|     // Stats queries | ||||
|     @Query("SELECT e.type, COUNT(e) FROM PersistentAuditEvent e GROUP BY e.type") | ||||
|     List<Object[]> countByType(); | ||||
|      | ||||
|     @Query("SELECT e.principal, COUNT(e) FROM PersistentAuditEvent e GROUP BY e.principal") | ||||
|     List<Object[]> countByPrincipal(); | ||||
|      | ||||
|     // Get distinct event types for filtering | ||||
|     @Query("SELECT DISTINCT e.type FROM PersistentAuditEvent e ORDER BY e.type") | ||||
|     List<String> findDistinctEventTypes(); | ||||
| } | ||||
| @ -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, | ||||
|  | ||||
| @ -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 { | ||||
|  | ||||
| @ -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 { | ||||
|  | ||||
| @ -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 { | ||||
|  | ||||
| @ -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 {} | ||||
| @ -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(); | ||||
|     } | ||||
| } | ||||
| @ -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<Long> 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<Long> 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<Long> batchIds) { | ||||
|         if (batchIds.isEmpty()) { | ||||
|             return 0; | ||||
|         } | ||||
|          | ||||
|         int batchSize = batchIds.size(); | ||||
|         auditRepository.deleteAllByIdInBatch(batchIds); | ||||
|         log.debug("Deleted batch of {} audit events", batchSize); | ||||
|          | ||||
|         return batchSize; | ||||
|     } | ||||
| } | ||||
| @ -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<String, Object> 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<String, Object> 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<String, Object> 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<String, Object> 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<String, Object> 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<String, Object> 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<String, Object> 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<String, Object> 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"; | ||||
|     } | ||||
| } | ||||
| @ -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<String,Object> mask(Map<String,Object> 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); | ||||
|     } | ||||
| 
 | ||||
|    | ||||
| 
 | ||||
| } | ||||
| @ -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<String, String[]> 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"); | ||||
|         } | ||||
|     } | ||||
| } | ||||
| @ -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); | ||||
|         } | ||||
|     } | ||||
| } | ||||
							
								
								
									
										239
									
								
								proprietary/src/main/resources/static/css/audit-dashboard.css
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										239
									
								
								proprietary/src/main/resources/static/css/audit-dashboard.css
									
									
									
									
									
										Normal file
									
								
							| @ -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); | ||||
| } | ||||
							
								
								
									
										999
									
								
								proprietary/src/main/resources/static/js/audit/dashboard.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										999
									
								
								proprietary/src/main/resources/static/js/audit/dashboard.js
									
									
									
									
									
										Normal file
									
								
							| @ -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 =  | ||||
|             '<tr><td colspan="5" class="text-center"><div class="spinner-border spinner-border-sm" role="status"></div> ' + window.i18n.loading + '</td></tr>'; | ||||
|     } | ||||
|      | ||||
|     // 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 = `<tr><td colspan="5" class="text-center">${window.i18n.errorLoading} ${error.message}</td></tr>`; | ||||
|             } | ||||
|             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 = '<tr><td colspan="5" class="text-center">' + window.i18n.noEventsFound + '</td></tr>'; | ||||
|         return; | ||||
|     } | ||||
|      | ||||
|     try { | ||||
|         auditTableBody.innerHTML = ''; | ||||
|          | ||||
|         events.forEach((event, index) => { | ||||
|             try { | ||||
|                 const row = document.createElement('tr'); | ||||
|                 row.innerHTML = ` | ||||
|                     <td>${event.id || 'N/A'}</td> | ||||
|                     <td>${formatDate(event.timestamp)}</td> | ||||
|                     <td>${escapeHtml(event.principal || 'N/A')}</td> | ||||
|                     <td>${escapeHtml(event.type || 'N/A')}</td> | ||||
|                     <td><button class="btn btn-sm btn-outline-primary view-details">${window.i18n.viewDetails || 'View Details'}</button></td> | ||||
|                 `;
 | ||||
|                  | ||||
|                 // 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 = '<tr><td colspan="5" class="text-center">' + window.i18n.errorRendering + ' ' + e.message + '</td></tr>'; | ||||
|     } | ||||
| } | ||||
| 
 | ||||
| // 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, '"') | ||||
|         .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; | ||||
| } | ||||
							
								
								
									
										42
									
								
								proprietary/src/main/resources/templates/AUDIT_HELP.md
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										42
									
								
								proprietary/src/main/resources/templates/AUDIT_HELP.md
									
									
									
									
									
										Normal file
									
								
							| @ -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 | ||||
							
								
								
									
										250
									
								
								proprietary/src/main/resources/templates/AUDIT_USAGE.md
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										250
									
								
								proprietary/src/main/resources/templates/AUDIT_USAGE.md
									
									
									
									
									
										Normal file
									
								
							| @ -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<User> 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<User> getUser(@PathVariable String id) { | ||||
|         // Method implementation | ||||
|         return ResponseEntity.ok(user); | ||||
|     } | ||||
|      | ||||
|     // This method will be automatically audited | ||||
|     @PostMapping | ||||
|     public ResponseEntity<User> 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<Void> 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. | ||||
							
								
								
									
										383
									
								
								proprietary/src/main/resources/templates/audit/dashboard.html
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										383
									
								
								proprietary/src/main/resources/templates/audit/dashboard.html
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,383 @@ | ||||
| <!DOCTYPE html> | ||||
| <html th:lang="${#locale.language}" th:dir="#{language.direction}" th:data-language="${#locale.toString()}" xmlns:th="https://www.thymeleaf.org"> | ||||
| <head> | ||||
|     <th:block th:insert="~{fragments/common :: head(title='Audit Dashboard', header='Audit Dashboard')}"></th:block> | ||||
|      | ||||
|     <!-- Include Chart.js for visualizations --> | ||||
|     <script th:src="@{/js/thirdParty/chart.umd.min.js}"></script> | ||||
|      | ||||
|     <!-- Include custom CSS --> | ||||
|     <link rel="stylesheet" th:href="@{/css/audit-dashboard.css}" /> | ||||
| </head> | ||||
| <body> | ||||
|     <div id="page-container"> | ||||
|       <div id="content-wrap"> | ||||
|         <th:block th:insert="~{fragments/navbar.html :: navbar}"></th:block> | ||||
|          | ||||
|         <div class="container-fluid mt-4"> | ||||
|         <h1 class="mb-4" th:text="#{audit.dashboard.title}">Audit Dashboard</h1> | ||||
|          | ||||
|         <!-- System Status Card --> | ||||
|         <div class="card dashboard-card mb-4"> | ||||
|             <div class="card-header"> | ||||
|                 <h2 class="h5 mb-0" th:text="#{audit.dashboard.systemStatus}">Audit System Status</h2> | ||||
|             </div> | ||||
|             <div class="card-body"> | ||||
|                 <div class="row"> | ||||
|                     <div class="col-md-3"> | ||||
|                         <div class="stat-card"> | ||||
|                             <div class="stat-label" th:text="#{audit.dashboard.status}">Status</div> | ||||
|                             <div class="stat-number"> | ||||
|                                 <span th:if="${auditEnabled}" class="text-success" th:text="#{audit.dashboard.enabled}">Enabled</span> | ||||
|                                 <span th:unless="${auditEnabled}" class="text-danger" th:text="#{audit.dashboard.disabled}">Disabled</span> | ||||
|                             </div> | ||||
|                         </div> | ||||
|                     </div> | ||||
|                     <div class="col-md-3"> | ||||
|                         <div class="stat-card"> | ||||
|                             <div class="stat-label" th:text="#{audit.dashboard.currentLevel}">Current Level</div> | ||||
|                             <div class="stat-number"> | ||||
|                                 <span th:class="'level-indicator level-' + ${auditLevelInt}" th:text="${auditLevel}">STANDARD</span> | ||||
|                             </div> | ||||
|                         </div> | ||||
|                     </div> | ||||
|                     <div class="col-md-3"> | ||||
|                         <div class="stat-card"> | ||||
|                             <div class="stat-label" th:text="#{audit.dashboard.retentionPeriod}">Retention Period</div> | ||||
|                             <div class="stat-number" th:text="${retentionDays} + ' ' + #{audit.dashboard.days}">90 days</div> | ||||
|                         </div> | ||||
|                     </div> | ||||
|                     <div class="col-md-3"> | ||||
|                         <div class="stat-card"> | ||||
|                             <div class="stat-label" th:text="#{audit.dashboard.totalEvents}">Total Events</div> | ||||
|                             <div class="stat-number" id="total-events">-</div> | ||||
|                         </div> | ||||
|                     </div> | ||||
|                 </div> | ||||
|             </div> | ||||
|         </div> | ||||
|          | ||||
|         <!-- Tabs for different sections --> | ||||
|         <ul class="nav nav-tabs" id="auditTabs" role="tablist"> | ||||
|             <li class="nav-item" role="presentation"> | ||||
|                 <button class="nav-link active" id="dashboard-tab" data-bs-toggle="tab" data-bs-target="#dashboard" type="button" role="tab" aria-controls="dashboard" aria-selected="true" th:text="#{audit.dashboard.tab.dashboard}">Dashboard</button> | ||||
|             </li> | ||||
|             <li class="nav-item" role="presentation"> | ||||
|                 <button class="nav-link" id="events-tab" data-bs-toggle="tab" data-bs-target="#events" type="button" role="tab" aria-controls="events" aria-selected="false" th:text="#{audit.dashboard.tab.events}">Audit Events</button> | ||||
|             </li> | ||||
|             <li class="nav-item" role="presentation"> | ||||
|                 <button class="nav-link" id="export-tab" data-bs-toggle="tab" data-bs-target="#export" type="button" role="tab" aria-controls="export" aria-selected="false" th:text="#{audit.dashboard.tab.export}">Export</button> | ||||
|             </li> | ||||
|             <li class="nav-item" role="presentation"> | ||||
|             </li> | ||||
|         </ul> | ||||
|          | ||||
|         <div class="tab-content" id="auditTabsContent"> | ||||
|             <!-- Dashboard Tab --> | ||||
|             <div class="tab-pane fade show active" id="dashboard" role="tabpanel" aria-labelledby="dashboard-tab"> | ||||
|                 <div class="row mt-4"> | ||||
|                     <div class="col-md-6"> | ||||
|                         <div class="card dashboard-card"> | ||||
|                             <div class="card-header d-flex justify-content-between align-items-center"> | ||||
|                                 <h3 class="h5 mb-0" th:text="#{audit.dashboard.eventsByType}">Events by Type</h3> | ||||
|                                 <div class="btn-group"> | ||||
|                                     <button class="btn btn-sm btn-outline-secondary" onclick="loadStats(7)" th:text="#{audit.dashboard.period.7days}">7 Days</button> | ||||
|                                     <button class="btn btn-sm btn-outline-secondary" onclick="loadStats(30)" th:text="#{audit.dashboard.period.30days}">30 Days</button> | ||||
|                                     <button class="btn btn-sm btn-outline-secondary" onclick="loadStats(90)" th:text="#{audit.dashboard.period.90days}">90 Days</button> | ||||
|                                 </div> | ||||
|                             </div> | ||||
|                             <div class="card-body"> | ||||
|                                 <div class="chart-container position-relative"> | ||||
|                                     <div class="loading-overlay" id="type-chart-loading"> | ||||
|                                         <div class="spinner-border text-primary" role="status"> | ||||
|                                             <span class="visually-hidden" th:text="#{loading}">Loading...</span> | ||||
|                                         </div> | ||||
|                                     </div> | ||||
|                                     <canvas id="typeChart"></canvas> | ||||
|                                 </div> | ||||
|                             </div> | ||||
|                         </div> | ||||
|                     </div> | ||||
|                     <div class="col-md-6"> | ||||
|                         <div class="card dashboard-card"> | ||||
|                             <div class="card-header"> | ||||
|                                 <h3 class="h5 mb-0" th:text="#{audit.dashboard.eventsByUser}">Events by User</h3> | ||||
|                             </div> | ||||
|                             <div class="card-body"> | ||||
|                                 <div class="chart-container position-relative"> | ||||
|                                     <div class="loading-overlay" id="user-chart-loading"> | ||||
|                                         <div class="spinner-border text-primary" role="status"> | ||||
|                                             <span class="visually-hidden" th:text="#{loading}">Loading...</span> | ||||
|                                         </div> | ||||
|                                     </div> | ||||
|                                     <canvas id="userChart"></canvas> | ||||
|                                 </div> | ||||
|                             </div> | ||||
|                         </div> | ||||
|                     </div> | ||||
|                 </div> | ||||
|                 <div class="row mt-4"> | ||||
|                     <div class="col-12"> | ||||
|                         <div class="card dashboard-card"> | ||||
|                             <div class="card-header"> | ||||
|                                 <h3 class="h5 mb-0" th:text="#{audit.dashboard.eventsOverTime}">Events Over Time</h3> | ||||
|                             </div> | ||||
|                             <div class="card-body"> | ||||
|                                 <div class="chart-container position-relative"> | ||||
|                                     <div class="loading-overlay" id="time-chart-loading"> | ||||
|                                         <div class="spinner-border text-primary" role="status"> | ||||
|                                             <span class="visually-hidden" th:text="#{loading}">Loading...</span> | ||||
|                                         </div> | ||||
|                                     </div> | ||||
|                                     <canvas id="timeChart"></canvas> | ||||
|                                 </div> | ||||
|                             </div> | ||||
|                         </div> | ||||
|                     </div> | ||||
|                 </div> | ||||
|             </div> | ||||
|              | ||||
|             <!-- Events Tab --> | ||||
|             <div class="tab-pane fade" id="events" role="tabpanel" aria-labelledby="events-tab"> | ||||
|                 <div class="card dashboard-card mt-4"> | ||||
|                     <div class="card-header"> | ||||
|                         <h3 class="h5 mb-0" th:text="#{audit.dashboard.auditEvents}">Audit Events</h3> | ||||
|                     </div> | ||||
|                     <div class="card-body"> | ||||
|                         <!-- Filters --> | ||||
|                         <div class="card filter-card"> | ||||
|                             <div class="row"> | ||||
|                                 <div class="col-md-3"> | ||||
|                                     <div class="mb-3"> | ||||
|                                         <label for="typeFilter" class="form-label" th:text="#{audit.dashboard.filter.eventType}">Event Type</label> | ||||
|                                         <select class="form-select" id="typeFilter"> | ||||
|                                             <option value="" th:text="#{audit.dashboard.filter.allEventTypes}">All event types</option> | ||||
|                                             <!-- Will be populated from API --> | ||||
|                                         </select> | ||||
|                                     </div> | ||||
|                                 </div> | ||||
|                                 <div class="col-md-3"> | ||||
|                                     <div class="mb-3"> | ||||
|                                         <label for="principalFilter" class="form-label" th:text="#{audit.dashboard.filter.user}">User</label> | ||||
|                                         <input type="text" class="form-control" id="principalFilter" th:placeholder="#{audit.dashboard.filter.userPlaceholder}" placeholder="Filter by user"> | ||||
|                                     </div> | ||||
|                                 </div> | ||||
|                                 <div class="col-md-3"> | ||||
|                                     <div class="mb-3"> | ||||
|                                         <label for="startDateFilter" class="form-label" th:text="#{audit.dashboard.filter.startDate}">Start Date</label> | ||||
|                                         <input type="date" class="form-control" id="startDateFilter"> | ||||
|                                     </div> | ||||
|                                 </div> | ||||
|                                 <div class="col-md-3"> | ||||
|                                     <div class="mb-3"> | ||||
|                                         <label for="endDateFilter" class="form-label" th:text="#{audit.dashboard.filter.endDate}">End Date</label> | ||||
|                                         <input type="date" class="form-control" id="endDateFilter"> | ||||
|                                     </div> | ||||
|                                 </div> | ||||
|                             </div> | ||||
|                             <div class="row"> | ||||
|                                 <div class="col-12"> | ||||
|                                     <button id="applyFilters" class="btn btn-primary" th:text="#{audit.dashboard.filter.apply}">Apply Filters</button> | ||||
|                                     <button id="resetFilters" class="btn btn-secondary" th:text="#{reset}">Reset</button> | ||||
|                                 </div> | ||||
|                             </div> | ||||
|                         </div> | ||||
|                          | ||||
|                         <!-- Event Table --> | ||||
|                         <div class="table-responsive position-relative"> | ||||
|                             <div class="loading-overlay" id="table-loading"> | ||||
|                                 <div class="spinner-border text-primary" role="status"> | ||||
|                                     <span class="visually-hidden" th:text="#{loading}">Loading...</span> | ||||
|                                 </div> | ||||
|                             </div> | ||||
|                             <table class="table table-striped table-hover audit-table"> | ||||
|                                 <thead> | ||||
|                                     <tr> | ||||
|                                         <th th:text="#{audit.dashboard.table.id}">ID</th> | ||||
|                                         <th th:text="#{audit.dashboard.table.time}">Time</th> | ||||
|                                         <th th:text="#{audit.dashboard.table.user}">User</th> | ||||
|                                         <th th:text="#{audit.dashboard.table.type}">Type</th> | ||||
|                                         <th th:text="#{audit.dashboard.table.details}">Details</th> | ||||
|                                     </tr> | ||||
|                                 </thead> | ||||
|                                 <tbody id="auditTableBody"> | ||||
|                                     <!-- Table rows will be populated by JavaScript --> | ||||
|                                 </tbody> | ||||
|                             </table> | ||||
|                         </div> | ||||
|                          | ||||
|                         <!-- Pagination --> | ||||
|                         <div class="pagination-container"> | ||||
|                             <div> | ||||
|                                 <span th:text="#{audit.dashboard.pagination.show}">Show</span> | ||||
|                                 <select id="pageSizeSelect" class="form-select form-select-sm d-inline-block w-auto mx-2"> | ||||
|                                     <option value="10">10</option> | ||||
|                                     <option value="20" selected>20</option> | ||||
|                                     <option value="50">50</option> | ||||
|                                     <option value="100">100</option> | ||||
|                                 </select> | ||||
|                                 <span th:text="#{audit.dashboard.pagination.entries}">entries</span> | ||||
|                                 <span class="mx-3" th:text="#{audit.dashboard.pagination.pageInfo1}">Page </span><span id="currentPage">1</span> <span th:text="#{audit.dashboard.pagination.pageInfo2}">of</span> <span id="totalPages">1</span> (<span th:text="#{audit.dashboard.pagination.totalRecords}">Total records:</span> <span id="totalRecords">0</span>) | ||||
|                             </div> | ||||
|                             <nav aria-label="Audit events pagination"> | ||||
|                                 <div class="btn-group" role="group" aria-label="Pagination"> | ||||
|                                     <button type="button" class="btn btn-outline-primary" id="page-first">«</button> | ||||
|                                     <button type="button" class="btn btn-outline-primary" id="page-prev">‹</button> | ||||
|                                     <span class="btn btn-outline-secondary disabled" id="page-indicator">Page 1 of 1</span> | ||||
|                                     <button type="button" class="btn btn-outline-primary" id="page-next">›</button> | ||||
|                                     <button type="button" class="btn btn-outline-primary" id="page-last">»</button> | ||||
|                                 </div> | ||||
|                             </nav> | ||||
|                         </div> | ||||
|                     </div> | ||||
|                 </div> | ||||
|                  | ||||
|                 <!-- Event Details Modal --> | ||||
|                 <div class="modal fade" id="eventDetailsModal" tabindex="-1" aria-labelledby="eventDetailsModalLabel" aria-hidden="true"> | ||||
|                     <div class="modal-dialog modal-lg modal-dialog-centered"> | ||||
|                         <div class="modal-content"> | ||||
|                             <div class="modal-header"> | ||||
|                                 <h5 class="modal-title" id="eventDetailsModalLabel" th:text="#{audit.dashboard.modal.eventDetails}">Event Details</h5> | ||||
|                                 <button type="button" class="btn-close" data-bs-dismiss="modal" th:aria-label="#{close}" aria-label="Close"></button> | ||||
|                             </div> | ||||
|                             <div class="modal-body"> | ||||
|                                 <div class="row mb-3"> | ||||
|                                     <div class="col-md-4"> | ||||
|                                         <strong th:text="#{audit.dashboard.modal.id} + ':'">ID:</strong> <span id="modal-id"></span> | ||||
|                                     </div> | ||||
|                                     <div class="col-md-4"> | ||||
|                                         <strong th:text="#{audit.dashboard.modal.user} + ':'">User:</strong> <span id="modal-principal"></span> | ||||
|                                     </div> | ||||
|                                     <div class="col-md-4"> | ||||
|                                         <strong th:text="#{audit.dashboard.modal.type} + ':'">Type:</strong> <span id="modal-type"></span> | ||||
|                                     </div> | ||||
|                                 </div> | ||||
|                                 <div class="row mb-3"> | ||||
|                                     <div class="col-md-12"> | ||||
|                                         <strong th:text="#{audit.dashboard.modal.time} + ':'">Time:</strong> <span id="modal-timestamp"></span> | ||||
|                                     </div> | ||||
|                                 </div> | ||||
|                                 <div class="row"> | ||||
|                                     <div class="col-md-12"> | ||||
|                                         <strong th:text="#{audit.dashboard.modal.data} + ':'">Data:</strong> | ||||
|                                         <div class="json-viewer" id="modal-data"></div> | ||||
|                                     </div> | ||||
|                                 </div> | ||||
|                             </div> | ||||
|                             <div class="modal-footer"> | ||||
|                                 <button type="button" class="btn btn-secondary" data-bs-dismiss="modal" th:text="#{close}">Close</button> | ||||
|                             </div> | ||||
|                         </div> | ||||
|                     </div> | ||||
|                 </div> | ||||
|             </div> | ||||
|              | ||||
|             <!-- Export Tab --> | ||||
|             <div class="tab-pane fade" id="export" role="tabpanel" aria-labelledby="export-tab"> | ||||
|                 <div class="card dashboard-card mt-4"> | ||||
|                     <div class="card-header"> | ||||
|                         <h3 class="h5 mb-0" th:text="#{audit.dashboard.export.title}">Export Audit Data</h3> | ||||
|                     </div> | ||||
|                     <div class="card-body"> | ||||
|                         <!-- Export Filters --> | ||||
|                         <div class="card filter-card"> | ||||
|                             <div class="row"> | ||||
|                                 <div class="col-md-3"> | ||||
|                                     <div class="mb-3"> | ||||
|                                         <label for="exportTypeFilter" class="form-label" th:text="#{audit.dashboard.filter.eventType}">Event Type</label> | ||||
|                                         <select class="form-select" id="exportTypeFilter"> | ||||
|                                             <option value="" th:text="#{audit.dashboard.filter.allEventTypes}">All event types</option> | ||||
|                                             <!-- Will be populated from API --> | ||||
|                                         </select> | ||||
|                                     </div> | ||||
|                                 </div> | ||||
|                                 <div class="col-md-3"> | ||||
|                                     <div class="mb-3"> | ||||
|                                         <label for="exportPrincipalFilter" class="form-label" th:text="#{audit.dashboard.filter.user}">User</label> | ||||
|                                         <input type="text" class="form-control" id="exportPrincipalFilter" th:placeholder="#{audit.dashboard.filter.userPlaceholder}" placeholder="Filter by user"> | ||||
|                                     </div> | ||||
|                                 </div> | ||||
|                                 <div class="col-md-3"> | ||||
|                                     <div class="mb-3"> | ||||
|                                         <label for="exportStartDateFilter" class="form-label" th:text="#{audit.dashboard.filter.startDate}">Start Date</label> | ||||
|                                         <input type="date" class="form-control" id="exportStartDateFilter"> | ||||
|                                     </div> | ||||
|                                 </div> | ||||
|                                 <div class="col-md-3"> | ||||
|                                     <div class="mb-3"> | ||||
|                                         <label for="exportEndDateFilter" class="form-label" th:text="#{audit.dashboard.filter.endDate}">End Date</label> | ||||
|                                         <input type="date" class="form-control" id="exportEndDateFilter"> | ||||
|                                     </div> | ||||
|                                 </div> | ||||
|                             </div> | ||||
|                             <div class="row mt-3"> | ||||
|                                 <div class="col-md-6"> | ||||
|                                     <h5 th:text="#{audit.dashboard.export.format}">Export Format</h5> | ||||
|                                     <div> | ||||
|                                         <label class="btn btn-outline-primary" style="margin-right: 10px;"> | ||||
|                                             <input type="radio" name="exportFormat" id="formatCSV" value="csv" checked style="margin-right: 5px;">  | ||||
|                                             <span th:text="#{audit.dashboard.export.csv}">CSV (Comma Separated Values)</span> | ||||
|                                         </label> | ||||
|                                         <label class="btn btn-outline-primary"> | ||||
|                                             <input type="radio" name="exportFormat" id="formatJSON" value="json" style="margin-right: 5px;">  | ||||
|                                             <span th:text="#{audit.dashboard.export.json}">JSON (JavaScript Object Notation)</span> | ||||
|                                         </label> | ||||
|                                     </div> | ||||
|                                 </div> | ||||
|                                 <div class="col-md-6"> | ||||
|                                     <button id="exportButton" class="btn btn-primary mt-4"> | ||||
|                                         <i class="bi bi-download"></i> <span th:text="#{audit.dashboard.export.button}">Export Data</span> | ||||
|                                     </button> | ||||
|                                     <button id="resetExportFilters" class="btn btn-secondary mt-4 ms-2"> | ||||
|                                         <span th:text="#{audit.dashboard.filter.reset}">Reset Filters</span> | ||||
|                                     </button> | ||||
|                                 </div> | ||||
|                             </div> | ||||
|                         </div> | ||||
|                          | ||||
|                         <div class="alert alert-info mt-3"> | ||||
|                             <h5 th:text="#{audit.dashboard.export.infoTitle}">Export Information</h5> | ||||
|                             <p th:text="#{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.</p> | ||||
|                             <p th:text="#{audit.dashboard.export.infoDesc2}">Exported data will include:</p> | ||||
|                             <ul> | ||||
|                                 <li th:text="#{audit.dashboard.export.infoItem1}">Event ID</li> | ||||
|                                 <li th:text="#{audit.dashboard.export.infoItem2}">User</li> | ||||
|                                 <li th:text="#{audit.dashboard.export.infoItem3}">Event Type</li> | ||||
|                                 <li th:text="#{audit.dashboard.export.infoItem4}">Timestamp</li> | ||||
|                                 <li th:text="#{audit.dashboard.export.infoItem5}">Event Data</li> | ||||
|                             </ul> | ||||
|                         </div> | ||||
|                     </div> | ||||
|                 </div> | ||||
|             </div> | ||||
|              | ||||
|         </div> | ||||
|     </div> | ||||
|      | ||||
|     <!-- Bootstrap JS is loaded by the common fragments --> | ||||
|     <script th:src="@{/js/thirdParty/jquery.min.js}"></script> | ||||
|     <script th:src="@{/js/thirdParty/bootstrap.min.js}"></script> | ||||
|      | ||||
|     <!-- Internationalization data for JavaScript --> | ||||
|     <script th:inline="javascript"> | ||||
|         window.i18n = { | ||||
|             loading: /*[[#{loading}]]*/ 'Loading...', | ||||
|             noEventsFound: /*[[#{audit.dashboard.js.noEventsFound}]]*/ 'No audit events found matching the current filters', | ||||
|             errorLoading: /*[[#{audit.dashboard.js.errorLoading}]]*/ 'Error loading data:', | ||||
|             errorRendering: /*[[#{audit.dashboard.js.errorRendering}]]*/ 'Error rendering table:', | ||||
|             loadingPage: /*[[#{audit.dashboard.js.loadingPage}]]*/ 'Loading page', | ||||
|             eventsByType: /*[[#{audit.dashboard.eventsByType}]]*/ 'Events by Type', | ||||
|             eventsByUser: /*[[#{audit.dashboard.eventsByUser}]]*/ 'Events by User', | ||||
|             eventsOverTime: /*[[#{audit.dashboard.eventsOverTime}]]*/ 'Events Over Time', | ||||
|             viewDetails: /*[[#{audit.dashboard.table.viewDetails}]]*/ 'View Details' | ||||
|         }; | ||||
|     </script> | ||||
|      | ||||
|     <!-- Load custom JavaScript --> | ||||
|     <script th:src="@{/js/audit/dashboard.js}"></script> | ||||
|         </div> | ||||
|       </div> | ||||
|       <th:block th:insert="~{fragments/footer.html :: footer}"></th:block> | ||||
|     </div> | ||||
|   </body> | ||||
| </html> | ||||
| @ -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"}; | ||||
|         } | ||||
|     } | ||||
|  | ||||
| @ -13,17 +13,11 @@ import jakarta.servlet.http.HttpServletResponse; | ||||
| 
 | ||||
| public class CleanUrlInterceptor implements HandlerInterceptor { | ||||
| 
 | ||||
|     private static final List<String> ALLOWED_PARAMS = | ||||
|             Arrays.asList( | ||||
|                     "lang", | ||||
|                     "endpoint", | ||||
|                     "endpoints", | ||||
|                     "logout", | ||||
|                     "error", | ||||
|                     "errorOAuth", | ||||
|                     "file", | ||||
|                     "messageType", | ||||
|                     "infoMessage"); | ||||
| 	private static final List<String> ALLOWED_PARAMS = Arrays.asList( | ||||
| 		    "lang", "endpoint", "endpoints", "logout", "error", "errorOAuth", "file", "messageType", "infoMessage", | ||||
| 		    "page", "size", "type", "principal", "startDate", "endDate" | ||||
| 		); | ||||
| 
 | ||||
| 
 | ||||
|     @Override | ||||
|     public boolean preHandle( | ||||
|  | ||||
| @ -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   # | ||||
| #################### | ||||
|  | ||||
| @ -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 | ||||
|  | ||||
| @ -112,6 +112,11 @@ | ||||
|                   <span class="material-symbols-rounded">analytics</span> | ||||
|                   <span th:text="#{adminUserSettings.usage}">Usage Statistics</span> | ||||
|                 </a> | ||||
|                  | ||||
|                 <a href="/audit" th:if="${@runningEE}" class="data-btn data-btn-secondary" title="Audit Dashboard"> | ||||
|                   <span class="material-symbols-rounded">security</span> | ||||
|                   <span>Audit Dashboard</span> | ||||
|                 </a> | ||||
|               </div> | ||||
|                | ||||
|               <!-- Users Table --> | ||||
|  | ||||
		Loading…
	
		Reference in New Issue
	
	Block a user