From a3ccc677b1d0061955fc665bcd2395f3c415cf21 Mon Sep 17 00:00:00 2001 From: Anthony Stirling <77850077+Frooodle@users.noreply.github.com.> Date: Fri, 13 Jun 2025 20:30:29 +0100 Subject: [PATCH] audit init --- .../common/model/ApplicationProperties.java | 9 + convert_properties_to_utf8.sh | 58 ++ proprietary/build.gradle | 1 + .../proprietary/audit/AuditAspect.java | 105 ++ .../proprietary/audit/AuditEventType.java | 64 ++ .../proprietary/audit/AuditLevel.java | 76 ++ .../software/proprietary/audit/Audited.java | 67 ++ .../audit/ControllerAuditAspect.java | 252 +++++ .../proprietary/config/AsyncConfig.java | 24 + .../config/AuditConfigurationProperties.java | 51 + .../proprietary/config/AuditJpaConfig.java | 19 + .../config/CustomAuditEventRepository.java | 61 ++ .../config/HttpRequestAuditPublisher.java | 116 +++ .../controller/AuditDashboardController.java | 301 ++++++ .../controller/AuditExampleController.java | 133 +++ .../model/security/PersistentAuditEvent.java | 24 + .../PersistentAuditEventRepository.java | 49 + .../CustomAuthenticationFailureHandler.java | 6 + .../CustomAuthenticationSuccessHandler.java | 6 + .../security/CustomLogoutSuccessHandler.java | 4 + .../service/AuditCleanupService.java | 53 + .../proprietary/service/AuditService.java | 157 +++ .../proprietary/util/SecretMasker.java | 34 + .../proprietary/web/AuditWebFilter.java | 97 ++ .../proprietary/web/CorrelationIdFilter.java | 46 + .../application-proprietary.properties | 12 + .../main/resources/templates/AUDIT_USAGE.md | 250 +++++ .../resources/templates/audit/dashboard.html | 947 ++++++++++++++++++ .../src/main/resources/settings.yml.template | 4 + .../resources/templates/adminSettings.html | 5 + 30 files changed, 3031 insertions(+) create mode 100644 convert_properties_to_utf8.sh create mode 100644 proprietary/src/main/java/stirling/software/proprietary/audit/AuditAspect.java create mode 100644 proprietary/src/main/java/stirling/software/proprietary/audit/AuditEventType.java create mode 100644 proprietary/src/main/java/stirling/software/proprietary/audit/AuditLevel.java create mode 100644 proprietary/src/main/java/stirling/software/proprietary/audit/Audited.java create mode 100644 proprietary/src/main/java/stirling/software/proprietary/audit/ControllerAuditAspect.java create mode 100644 proprietary/src/main/java/stirling/software/proprietary/config/AsyncConfig.java create mode 100644 proprietary/src/main/java/stirling/software/proprietary/config/AuditConfigurationProperties.java create mode 100644 proprietary/src/main/java/stirling/software/proprietary/config/AuditJpaConfig.java create mode 100644 proprietary/src/main/java/stirling/software/proprietary/config/CustomAuditEventRepository.java create mode 100644 proprietary/src/main/java/stirling/software/proprietary/config/HttpRequestAuditPublisher.java create mode 100644 proprietary/src/main/java/stirling/software/proprietary/controller/AuditDashboardController.java create mode 100644 proprietary/src/main/java/stirling/software/proprietary/controller/AuditExampleController.java create mode 100644 proprietary/src/main/java/stirling/software/proprietary/model/security/PersistentAuditEvent.java create mode 100644 proprietary/src/main/java/stirling/software/proprietary/repository/PersistentAuditEventRepository.java create mode 100644 proprietary/src/main/java/stirling/software/proprietary/service/AuditCleanupService.java create mode 100644 proprietary/src/main/java/stirling/software/proprietary/service/AuditService.java create mode 100644 proprietary/src/main/java/stirling/software/proprietary/util/SecretMasker.java create mode 100644 proprietary/src/main/java/stirling/software/proprietary/web/AuditWebFilter.java create mode 100644 proprietary/src/main/java/stirling/software/proprietary/web/CorrelationIdFilter.java create mode 100644 proprietary/src/main/resources/application-proprietary.properties create mode 100644 proprietary/src/main/resources/templates/AUDIT_USAGE.md create mode 100644 proprietary/src/main/resources/templates/audit/dashboard.html diff --git a/common/src/main/java/stirling/software/common/model/ApplicationProperties.java b/common/src/main/java/stirling/software/common/model/ApplicationProperties.java index 58ff13f50..1c514b0a0 100644 --- a/common/src/main/java/stirling/software/common/model/ApplicationProperties.java +++ b/common/src/main/java/stirling/software/common/model/ApplicationProperties.java @@ -442,8 +442,17 @@ public class ApplicationProperties { @Data public static class ProFeatures { private boolean ssoAutoLogin; + private boolean database; private CustomMetadata customMetadata = new CustomMetadata(); private GoogleDrive googleDrive = new GoogleDrive(); + 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 CustomMetadata { diff --git a/convert_properties_to_utf8.sh b/convert_properties_to_utf8.sh new file mode 100644 index 000000000..5ca29cde7 --- /dev/null +++ b/convert_properties_to_utf8.sh @@ -0,0 +1,58 @@ +#!/bin/bash + +# Check if iconv is installed +if ! command -v iconv &> /dev/null; then + echo "Error: iconv is required but not installed." + exit 1 +fi + +# Directory containing property files +PROP_DIR="stirling-pdf/src/main/resources" + +# List of files to convert +FILES=( + "stirling-pdf/src/main/resources/messages_az_AZ.properties" + "stirling-pdf/src/main/resources/messages_ca_CA.properties" + "stirling-pdf/src/main/resources/messages_cs_CZ.properties" + "stirling-pdf/src/main/resources/messages_da_DK.properties" + "stirling-pdf/src/main/resources/messages_de_DE.properties" + "stirling-pdf/src/main/resources/messages_es_ES.properties" + "stirling-pdf/src/main/resources/messages_fr_FR.properties" + "stirling-pdf/src/main/resources/messages_ga_IE.properties" + "stirling-pdf/src/main/resources/messages_hu_HU.properties" + "stirling-pdf/src/main/resources/messages_it_IT.properties" + "stirling-pdf/src/main/resources/messages_nl_NL.properties" + "stirling-pdf/src/main/resources/messages_no_NB.properties" + "stirling-pdf/src/main/resources/messages_pl_PL.properties" + "stirling-pdf/src/main/resources/messages_pt_BR.properties" + "stirling-pdf/src/main/resources/messages_pt_PT.properties" + "stirling-pdf/src/main/resources/messages_ro_RO.properties" + "stirling-pdf/src/main/resources/messages_ru_RU.properties" + "stirling-pdf/src/main/resources/messages_sk_SK.properties" + "stirling-pdf/src/main/resources/messages_sv_SE.properties" + "stirling-pdf/src/main/resources/messages_tr_TR.properties" + "stirling-pdf/src/main/resources/messages_uk_UA.properties" + "stirling-pdf/src/main/resources/messages_vi_VN.properties" +) + +for file in "${FILES[@]}"; do + echo "Processing $file..." + + # Create a backup of the original file + cp "$file" "${file}.bak" + + # Convert from ISO-8859-1 to UTF-8 + iconv -f ISO-8859-1 -t UTF-8 "${file}.bak" > "$file" + + # Check if conversion was successful + if [ $? -eq 0 ]; then + echo "Successfully converted $file to UTF-8" + # Verify the file is now UTF-8 + file "$file" + else + echo "Failed to convert $file, restoring backup" + mv "${file}.bak" "$file" + fi +done + +echo "All files processed." \ No newline at end of file diff --git a/proprietary/build.gradle b/proprietary/build.gradle index 7fdc4bb6c..6a5730f84 100644 --- a/proprietary/build.gradle +++ b/proprietary/build.gradle @@ -18,6 +18,7 @@ dependencies { api 'org.springframework.boot:spring-boot-starter-oauth2-client' api 'org.springframework.boot:spring-boot-starter-mail' api 'io.swagger.core.v3:swagger-core-jakarta:2.2.30' + api 'org.springframework.boot:spring-boot-starter-validation' implementation 'com.bucket4j:bucket4j_jdk17-core:8.14.0' // https://mvnrepository.com/artifact/com.bucket4j/bucket4j_jdk17 diff --git a/proprietary/src/main/java/stirling/software/proprietary/audit/AuditAspect.java b/proprietary/src/main/java/stirling/software/proprietary/audit/AuditAspect.java new file mode 100644 index 000000000..aadfe15d0 --- /dev/null +++ b/proprietary/src/main/java/stirling/software/proprietary/audit/AuditAspect.java @@ -0,0 +1,105 @@ +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 stirling.software.proprietary.config.AuditConfigurationProperties; +import stirling.software.proprietary.service.AuditService; + +import java.lang.reflect.Method; +import java.util.HashMap; +import java.util.Map; +import java.util.stream.IntStream; + +/** + * 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); + + // Skip if this audit level is not enabled + if (!auditConfig.isLevelEnabled(auditedAnnotation.level())) { + return joinPoint.proceed(); + } + + Map auditData = new HashMap<>(); + auditData.put("className", joinPoint.getTarget().getClass().getName()); + auditData.put("methodName", method.getName()); + + // 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) { + Object[] args = joinPoint.getArgs(); + String[] parameterNames = signature.getParameterNames(); + + if (args != null && parameterNames != null) { + IntStream.range(0, args.length) + .forEach(i -> { + String paramName = i < parameterNames.length ? parameterNames[i] : "arg" + i; + auditData.put("arg_" + paramName, args[i]); + }); + } + } + + 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) { + auditData.put("result", result.toString()); + } + + 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 { + // Create the audit entry with the specified level + // Determine which type of event identifier to use (enum or string) + AuditEventType eventType = auditedAnnotation.type(); + String typeString = auditedAnnotation.typeString(); + + if (eventType != AuditEventType.HTTP_REQUEST || !StringUtils.isNotEmpty(typeString)) { + // Use the enum type (preferred) + auditService.audit(eventType, auditData, auditedAnnotation.level()); + } else { + // Use the string type (for backward compatibility) + auditService.audit(typeString, auditData, auditedAnnotation.level()); + } + } + } +} \ No newline at end of file diff --git a/proprietary/src/main/java/stirling/software/proprietary/audit/AuditEventType.java b/proprietary/src/main/java/stirling/software/proprietary/audit/AuditEventType.java new file mode 100644 index 000000000..85e7fd245 --- /dev/null +++ b/proprietary/src/main/java/stirling/software/proprietary/audit/AuditEventType.java @@ -0,0 +1,64 @@ +package stirling.software.proprietary.audit; + +/** + * Standardized audit event types for the application. + * Using an enum ensures consistency in event type naming and categorization. + */ +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_UPLOAD("File uploaded"), + FILE_DOWNLOAD("File downloaded"), + + // PDF operations - STANDARD level + PDF_PROCESS("PDF processing operation"), + + // HTTP requests - STANDARD level + HTTP_REQUEST("HTTP request"); + + private final String description; + + AuditEventType(String description) { + this.description = description; + } + + public String getDescription() { + return description; + } + + /** + * Get the enum value from a string representation. + * Useful for backward compatibility with string-based event types. + * + * @param type The string representation of the event type + * @return The corresponding enum value or null if not found + */ + public static AuditEventType fromString(String type) { + if (type == null) { + return null; + } + + try { + return AuditEventType.valueOf(type); + } catch (IllegalArgumentException e) { + // If the exact enum name doesn't match, try finding a similar one + for (AuditEventType eventType : values()) { + if (eventType.name().equalsIgnoreCase(type) || + eventType.getDescription().equalsIgnoreCase(type)) { + return eventType; + } + } + return null; + } + } +} \ No newline at end of file diff --git a/proprietary/src/main/java/stirling/software/proprietary/audit/AuditLevel.java b/proprietary/src/main/java/stirling/software/proprietary/audit/AuditLevel.java new file mode 100644 index 000000000..d6b61f488 --- /dev/null +++ b/proprietary/src/main/java/stirling/software/proprietary/audit/AuditLevel.java @@ -0,0 +1,76 @@ +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) { + for (AuditLevel auditLevel : values()) { + if (auditLevel.level == level) { + return auditLevel; + } + } + // Default to STANDARD if invalid level + return STANDARD; + } +} \ No newline at end of file diff --git a/proprietary/src/main/java/stirling/software/proprietary/audit/Audited.java b/proprietary/src/main/java/stirling/software/proprietary/audit/Audited.java new file mode 100644 index 000000000..dff976d8e --- /dev/null +++ b/proprietary/src/main/java/stirling/software/proprietary/audit/Audited.java @@ -0,0 +1,67 @@ +package stirling.software.proprietary.audit; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/** + * Annotation for methods that should be audited. + * + * Usage: + * + *
+ * {@code 
+ * @Audited(type = AuditEventType.USER_REGISTRATION, level = AuditLevel.BASIC)
+ * public void registerUser(String username) {
+ *    // Method implementation
+ * }
+ * }
+ * 
+ * + * For backward compatibility, string-based event types are still supported: + * + *
+ * {@code 
+ * @Audited(typeString = "CUSTOM_EVENT_TYPE", level = AuditLevel.BASIC)
+ * public void customOperation() {
+ *    // Method implementation
+ * }
+ * }
+ * 
+ */ +@Target(ElementType.METHOD) +@Retention(RetentionPolicy.RUNTIME) +public @interface Audited { + + /** + * The type of audit event using the standardized AuditEventType enum. + * This is the preferred way to specify the event type. + * + * If both type() and typeString() are specified, type() takes precedence. + */ + AuditEventType type() default AuditEventType.HTTP_REQUEST; + + /** + * The type of audit event as a string (e.g., "FILE_UPLOAD", "USER_REGISTRATION"). + * Provided for backward compatibility and custom event types not in the enum. + * + * If both type() and typeString() are specified, type() takes precedence. + */ + String typeString() default ""; + + /** + * The audit level at which this event should be logged + */ + AuditLevel level() default AuditLevel.STANDARD; + + /** + * Should method arguments be included in the audit event + */ + boolean includeArgs() default true; + + /** + * Should the method return value be included in the audit event + */ + boolean includeResult() default false; +} \ No newline at end of file diff --git a/proprietary/src/main/java/stirling/software/proprietary/audit/ControllerAuditAspect.java b/proprietary/src/main/java/stirling/software/proprietary/audit/ControllerAuditAspect.java new file mode 100644 index 000000000..8b0b175ed --- /dev/null +++ b/proprietary/src/main/java/stirling/software/proprietary/audit/ControllerAuditAspect.java @@ -0,0 +1,252 @@ +package stirling.software.proprietary.audit; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +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 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; +import java.util.stream.IntStream; + +/** + * 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; + + /** + * Intercept all methods with GetMapping annotation + */ + @Around("@annotation(org.springframework.web.bind.annotation.GetMapping)") + public Object auditGetMethod(ProceedingJoinPoint joinPoint) throws Throwable { + return auditControllerMethod(joinPoint, "GET"); + } + + /** + * Intercept all methods with PostMapping annotation + */ + @Around("@annotation(org.springframework.web.bind.annotation.PostMapping)") + public Object auditPostMethod(ProceedingJoinPoint joinPoint) throws Throwable { + return auditControllerMethod(joinPoint, "POST"); + } + + /** + * Intercept all methods with PutMapping annotation + */ + @Around("@annotation(org.springframework.web.bind.annotation.PutMapping)") + public Object auditPutMethod(ProceedingJoinPoint joinPoint) throws Throwable { + return auditControllerMethod(joinPoint, "PUT"); + } + + /** + * Intercept all methods with DeleteMapping annotation + */ + @Around("@annotation(org.springframework.web.bind.annotation.DeleteMapping)") + public Object auditDeleteMethod(ProceedingJoinPoint joinPoint) throws Throwable { + return auditControllerMethod(joinPoint, "DELETE"); + } + + /** + * Intercept all methods with PatchMapping annotation + */ + @Around("@annotation(org.springframework.web.bind.annotation.PatchMapping)") + public Object auditPatchMethod(ProceedingJoinPoint joinPoint) throws Throwable { + return auditControllerMethod(joinPoint, "PATCH"); + } + + /** + * Common method to audit controller methods + */ + private Object auditControllerMethod(ProceedingJoinPoint joinPoint, String httpMethod) throws Throwable { + // Skip if below STANDARD level (controller auditing is considered STANDARD level) + if (!auditConfig.isLevelEnabled(AuditLevel.STANDARD)) { + return joinPoint.proceed(); + } + + MethodSignature signature = (MethodSignature) joinPoint.getSignature(); + Method method = signature.getMethod(); + + // Don't audit methods that already have @Audited annotation + if (method.isAnnotationPresent(Audited.class)) { + return joinPoint.proceed(); + } + + // Get the request path + String path = getRequestPath(method, httpMethod); + + // Create audit data + Map auditData = new HashMap<>(); + auditData.put("controller", joinPoint.getTarget().getClass().getSimpleName()); + auditData.put("method", method.getName()); + auditData.put("httpMethod", httpMethod); + auditData.put("path", path); + + // Add method parameters if at VERBOSE level + if (auditConfig.isLevelEnabled(AuditLevel.VERBOSE)) { + Object[] args = joinPoint.getArgs(); + String[] parameterNames = signature.getParameterNames(); + + if (args != null && parameterNames != null) { + IntStream.range(0, args.length) + .forEach(i -> { + String paramName = i < parameterNames.length ? parameterNames[i] : "arg" + i; + auditData.put("arg_" + paramName, args[i]); + }); + } + } + + Object result; + try { + // Execute the method + result = joinPoint.proceed(); + + // Add success status + auditData.put("status", "success"); + + // Add result if at VERBOSE level + if (auditConfig.isLevelEnabled(AuditLevel.VERBOSE) && result != null) { + auditData.put("resultType", result.getClass().getSimpleName()); + } + + return result; + } catch (Throwable ex) { + // Always add failure information + auditData.put("status", "failure"); + auditData.put("errorType", ex.getClass().getName()); + auditData.put("errorMessage", ex.getMessage()); + + // Re-throw the exception + throw ex; + } finally { + // Determine the appropriate audit event type based on the controller package and class name + AuditEventType eventType = determineAuditEventType(joinPoint.getTarget().getClass(), path, httpMethod); + + // Create the audit entry using the enum type + auditService.audit(eventType, auditData, AuditLevel.STANDARD); + } + } + + /** + * Determines the appropriate audit event type based on the controller's package and class name and HTTP method + */ + private AuditEventType determineAuditEventType(Class controllerClass, String path, String httpMethod) { + String className = controllerClass.getSimpleName().toLowerCase(); + String packageName = controllerClass.getPackage().getName().toLowerCase(); + + // For GET requests, just use HTTP_REQUEST as they don't process anything + if (httpMethod.equals("GET")) { + return AuditEventType.HTTP_REQUEST; + } + + // For actual processing operations (POST, PUT, DELETE, etc.) + + // User/authentication related controllers + if (className.contains("user") || className.contains("auth") || + packageName.contains("security") || packageName.contains("auth") || + path.startsWith("/user") || path.startsWith("/login") || + path.startsWith("/auth") || path.startsWith("/account")) { + return AuditEventType.USER_PROFILE_UPDATE; + } + + // Admin related controllers + else if (className.contains("admin") || path.startsWith("/admin") || + path.startsWith("/settings") || className.contains("setting") || + className.contains("database") || path.contains("database")) { + return AuditEventType.SETTINGS_CHANGED; + } + + // File operations + else if (className.contains("file") || path.contains("file")) { + if (path.contains("upload") || path.contains("add")) { + return AuditEventType.FILE_UPLOAD; + } else if (path.contains("download")) { + return AuditEventType.FILE_DOWNLOAD; + } else { + return AuditEventType.FILE_UPLOAD; + } + } + + // Default to PDF operations for most controllers + else { + return AuditEventType.PDF_PROCESS; + } + } + + /** + * Extracts the request path from the method's annotations + */ + private String getRequestPath(Method method, String httpMethod) { + // Check class level RequestMapping + String basePath = ""; + RequestMapping classMapping = method.getDeclaringClass().getAnnotation(RequestMapping.class); + if (classMapping != null && classMapping.value().length > 0) { + basePath = classMapping.value()[0]; + } + + // Check method level mapping + String methodPath = ""; + Annotation annotation = null; + + switch (httpMethod) { + case "GET": + annotation = method.getAnnotation(GetMapping.class); + if (annotation != null) { + String[] paths = ((GetMapping) annotation).value(); + if (paths.length > 0) methodPath = paths[0]; + } + break; + case "POST": + annotation = method.getAnnotation(PostMapping.class); + if (annotation != null) { + String[] paths = ((PostMapping) annotation).value(); + if (paths.length > 0) methodPath = paths[0]; + } + break; + case "PUT": + annotation = method.getAnnotation(PutMapping.class); + if (annotation != null) { + String[] paths = ((PutMapping) annotation).value(); + if (paths.length > 0) methodPath = paths[0]; + } + break; + case "DELETE": + annotation = method.getAnnotation(DeleteMapping.class); + if (annotation != null) { + String[] paths = ((DeleteMapping) annotation).value(); + if (paths.length > 0) methodPath = paths[0]; + } + break; + case "PATCH": + annotation = method.getAnnotation(PatchMapping.class); + if (annotation != null) { + String[] paths = ((PatchMapping) annotation).value(); + if (paths.length > 0) methodPath = paths[0]; + } + break; + } + + // Combine base path and method path + return basePath + methodPath; + } +} \ No newline at end of file diff --git a/proprietary/src/main/java/stirling/software/proprietary/config/AsyncConfig.java b/proprietary/src/main/java/stirling/software/proprietary/config/AsyncConfig.java new file mode 100644 index 000000000..f705efca8 --- /dev/null +++ b/proprietary/src/main/java/stirling/software/proprietary/config/AsyncConfig.java @@ -0,0 +1,24 @@ +package stirling.software.proprietary.config; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.scheduling.annotation.EnableAsync; +import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor; + +import java.util.concurrent.Executor; + +@Configuration +@EnableAsync +public class AsyncConfig { + + @Bean(name = "auditExecutor") + public Executor auditExecutor() { + ThreadPoolTaskExecutor exec = new ThreadPoolTaskExecutor(); + exec.setCorePoolSize(2); + exec.setMaxPoolSize(8); + exec.setQueueCapacity(1_000); + exec.setThreadNamePrefix("audit-"); + exec.initialize(); + return exec; + } +} \ No newline at end of file diff --git a/proprietary/src/main/java/stirling/software/proprietary/config/AuditConfigurationProperties.java b/proprietary/src/main/java/stirling/software/proprietary/config/AuditConfigurationProperties.java new file mode 100644 index 000000000..53950a8f0 --- /dev/null +++ b/proprietary/src/main/java/stirling/software/proprietary/config/AuditConfigurationProperties.java @@ -0,0 +1,51 @@ +package stirling.software.proprietary.config; + +import lombok.Getter; +import lombok.extern.slf4j.Slf4j; +import org.springframework.context.annotation.Configuration; +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.proFeatures.audit + */ +@Slf4j +@Getter +@Component +public class AuditConfigurationProperties { + + private final boolean enabled; + private final int level; + private final int retentionDays; + + public AuditConfigurationProperties(ApplicationProperties applicationProperties) { + ApplicationProperties.Premium.ProFeatures.Audit auditConfig = + applicationProperties.getPremium().getProFeatures().getAudit(); + + this.enabled = auditConfig.isEnabled(); + this.level = auditConfig.getLevel(); + this.retentionDays = auditConfig.getRetentionDays(); + + log.info("Initialized audit configuration: enabled={}, level={}, retentionDays={}", + 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); + } +} \ No newline at end of file diff --git a/proprietary/src/main/java/stirling/software/proprietary/config/AuditJpaConfig.java b/proprietary/src/main/java/stirling/software/proprietary/config/AuditJpaConfig.java new file mode 100644 index 000000000..c0ca3e4b4 --- /dev/null +++ b/proprietary/src/main/java/stirling/software/proprietary/config/AuditJpaConfig.java @@ -0,0 +1,19 @@ +package stirling.software.proprietary.config; + +import org.springframework.context.annotation.Configuration; +import org.springframework.data.jpa.repository.config.EnableJpaRepositories; +import org.springframework.scheduling.annotation.EnableScheduling; +import org.springframework.transaction.annotation.EnableTransactionManagement; + +/** + * Configuration to explicitly enable JPA repositories and scheduling for the audit system. + */ +@Configuration +@EnableTransactionManagement +@EnableJpaRepositories(basePackages = "stirling.software.proprietary.repository") +@EnableScheduling +public class AuditJpaConfig { + // This configuration enables JPA repositories in the specified package + // and enables scheduling for audit cleanup tasks + // No additional beans or methods needed +} \ No newline at end of file diff --git a/proprietary/src/main/java/stirling/software/proprietary/config/CustomAuditEventRepository.java b/proprietary/src/main/java/stirling/software/proprietary/config/CustomAuditEventRepository.java new file mode 100644 index 000000000..860ab7400 --- /dev/null +++ b/proprietary/src/main/java/stirling/software/proprietary/config/CustomAuditEventRepository.java @@ -0,0 +1,61 @@ +package stirling.software.proprietary.config; + +import com.fasterxml.jackson.databind.ObjectMapper; +import lombok.RequiredArgsConstructor; +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 +public class CustomAuditEventRepository implements AuditEventRepository { + + private final PersistentAuditEventRepository repo; + private final ObjectMapper mapper; + + /* ── READ side intentionally inert (endpoint disabled) ── */ + @Override + public List find(String p, Instant after, String type) { + return List.of(); + } + + /* ── WRITE side (async) ───────────────────────────────── */ + @Async("auditExecutor") + @Override + public void add(AuditEvent ev) { + try { + Map clean = + CollectionUtils.isEmpty(ev.getData()) + ? Map.of() + : SecretMasker.mask(ev.getData()); + + String rid = MDC.get("requestId"); + if (rid != null) { + clean = new java.util.HashMap<>(clean); + clean.put("requestId", rid); + } + + PersistentAuditEvent ent = PersistentAuditEvent.builder() + .principal(ev.getPrincipal()) + .type(ev.getType()) + .data(mapper.writeValueAsString(clean)) + .timestamp(ev.getTimestamp()) + .build(); + repo.save(ent); + } catch (Exception e) { + e.printStackTrace(); // fail-open + } + } +} \ No newline at end of file diff --git a/proprietary/src/main/java/stirling/software/proprietary/config/HttpRequestAuditPublisher.java b/proprietary/src/main/java/stirling/software/proprietary/config/HttpRequestAuditPublisher.java new file mode 100644 index 000000000..d8c604b00 --- /dev/null +++ b/proprietary/src/main/java/stirling/software/proprietary/config/HttpRequestAuditPublisher.java @@ -0,0 +1,116 @@ +package stirling.software.proprietary.config; + +import lombok.RequiredArgsConstructor; +import org.slf4j.MDC; +import org.springframework.boot.actuate.audit.AuditEvent; +import org.springframework.boot.actuate.audit.AuditEventRepository; +import org.springframework.context.ApplicationListener; +import org.springframework.stereotype.Component; +import org.springframework.util.StringUtils; +import org.springframework.web.context.support.ServletRequestHandledEvent; +import stirling.software.proprietary.audit.AuditLevel; +import stirling.software.proprietary.util.SecretMasker; + +import java.net.InetAddress; +import java.net.UnknownHostException; +import java.util.HashMap; +import java.util.Map; + +@Component +@RequiredArgsConstructor +public class HttpRequestAuditPublisher + implements ApplicationListener { + + private final AuditEventRepository repo; + private final AuditConfigurationProperties auditConfig; + + @Override + public void onApplicationEvent(ServletRequestHandledEvent e) { + // Skip if audit is disabled or level is OFF + if (!auditConfig.isEnabled() || auditConfig.getAuditLevel() == AuditLevel.OFF) { + return; + } + + // Basic request information is included at level STANDARD or higher + AuditLevel currentLevel = auditConfig.getAuditLevel(); + boolean isBasicLevel = currentLevel.includes(AuditLevel.BASIC); + boolean isStandardLevel = currentLevel.includes(AuditLevel.STANDARD); + boolean isVerboseLevel = currentLevel.includes(AuditLevel.VERBOSE); + + // Special case for errors - always log errors at BASIC level + boolean isError = e.getStatusCode() >= 400 || e.getFailureCause() != null; + + // Skip non-error requests if below STANDARD level + if (!isStandardLevel && !isError) { + return; + } + + // Create a mutable map to hold all our audit data + Map raw = new HashMap<>(); + + // Add basic request data from the event (always included) + raw.put("method", e.getMethod()); + raw.put("uri", e.getRequestUrl()); + raw.put("status", e.getStatusCode()); + raw.put("latency", e.getProcessingTimeMillis()); + raw.put("ip", e.getClientAddress()); + + // Add standard level data + if (isStandardLevel || isError) { + raw.put("servlet", e.getServletName()); + raw.put("sessionId", e.getSessionId()); + raw.put("requestId", MDC.get("requestId")); + raw.put("host", getHostName()); + raw.put("timestamp", System.currentTimeMillis()); + } + + // Check for failure information (always included for errors) + if (e.getFailureCause() != null) { + raw.put("failed", true); + raw.put("errorType", e.getFailureCause().getClass().getName()); + raw.put("errorMessage", e.getFailureCause().getMessage()); + } + + // Add additional data from MDC at VERBOSE level + if (isVerboseLevel) { + addFromMDC(raw, "userAgent"); + addFromMDC(raw, "referer"); + addFromMDC(raw, "acceptLanguage"); + addFromMDC(raw, "contentType"); + addFromMDC(raw, "userRoles"); + addFromMDC(raw, "queryParams"); + } + + // Determine the correct audit level for this event + AuditLevel eventLevel = isError ? AuditLevel.BASIC : + isVerboseLevel ? AuditLevel.VERBOSE : + AuditLevel.STANDARD; + + // Create the audit event + repo.add(new AuditEvent( + e.getUserName() != null ? e.getUserName() : "anonymous", + "HTTP_REQUEST", + SecretMasker.mask(raw))); + } + + /** + * Adds a value from MDC to the audit data map if present + */ + private void addFromMDC(Map data, String key) { + String value = MDC.get(key); + if (StringUtils.hasText(value)) { + data.put(key, value); + } + } + + /** + * Gets the hostname of the current server + */ + private String getHostName() { + try { + return InetAddress.getLocalHost().getHostName(); + } catch (UnknownHostException e) { + return "unknown-host"; + } + } +} \ No newline at end of file diff --git a/proprietary/src/main/java/stirling/software/proprietary/controller/AuditDashboardController.java b/proprietary/src/main/java/stirling/software/proprietary/controller/AuditDashboardController.java new file mode 100644 index 000000000..b09c7f57e --- /dev/null +++ b/proprietary/src/main/java/stirling/software/proprietary/controller/AuditDashboardController.java @@ -0,0 +1,301 @@ +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.HashMap; +import java.util.List; +import java.util.Map; +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 lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +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; + +/** + * Controller for the audit dashboard. + * Admin-only access. + */ +@Slf4j +@Controller +@RequestMapping("/audit") +@PreAuthorize("hasRole('ADMIN')") +@RequiredArgsConstructor +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()); + + return "audit/dashboard"; + } + + /** + * Get audit events data for the dashboard tables. + */ + @GetMapping("/data") + @ResponseBody + public Map getAuditData( + @RequestParam(value = "page", defaultValue = "0") int page, + @RequestParam(value = "size", defaultValue = "20") 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) { + + Pageable pageable = PageRequest.of(page, size, Sort.by("timestamp").descending()); + + // Create dynamic query based on parameters + Page 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.findByPrincipalAndTypeAndTimestampBetween( + principal, type, start, end, pageable); + } else if (type != null && principal != null) { + events = auditRepository.findByPrincipalAndType(principal, type, pageable); + } 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.findByTypeAndTimestampBetween(type, start, end, pageable); + } 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.findByPrincipalAndTimestampBetween(principal, start, end, pageable); + } else if (startDate != null && endDate != null) { + Instant start = startDate.atStartOfDay(ZoneId.systemDefault()).toInstant(); + Instant end = endDate.plusDays(1).atStartOfDay(ZoneId.systemDefault()).toInstant(); + events = auditRepository.findByTimestampBetween(start, end, pageable); + } else if (type != null) { + events = auditRepository.findByType(type, pageable); + } else if (principal != null) { + events = auditRepository.findByPrincipal(principal, pageable); + } else { + events = auditRepository.findAll(pageable); + } + + // Format the response + Map response = new HashMap<>(); + response.put("content", events.getContent()); + response.put("totalPages", events.getTotalPages()); + response.put("totalElements", events.getTotalElements()); + response.put("currentPage", events.getNumber()); + + return response; + } + + /** + * Get statistics for charts. + */ + @GetMapping("/stats") + @ResponseBody + public Map getAuditStats( + @RequestParam(value = "days", defaultValue = "7") int days) { + + // Get events from the last X days + Instant startDate = Instant.now().minus(java.time.Duration.ofDays(days)); + List events = auditRepository.findByTimestampAfter(startDate); + + // Count events by type + Map eventsByType = events.stream() + .collect(Collectors.groupingBy(PersistentAuditEvent::getType, Collectors.counting())); + + // Count events by principal + Map eventsByPrincipal = events.stream() + .collect(Collectors.groupingBy(PersistentAuditEvent::getPrincipal, Collectors.counting())); + + // Count events by day + Map eventsByDay = events.stream() + .collect(Collectors.groupingBy( + e -> LocalDateTime.ofInstant(e.getTimestamp(), ZoneId.systemDefault()) + .format(DateTimeFormatter.ISO_LOCAL_DATE), + Collectors.counting())); + + Map stats = new HashMap<>(); + stats.put("eventsByType", eventsByType); + stats.put("eventsByPrincipal", eventsByPrincipal); + stats.put("eventsByDay", eventsByDay); + stats.put("totalEvents", events.size()); + + return stats; + } + + /** + * Export audit data as CSV. + */ + @GetMapping("/export") + public ResponseEntity exportAuditData( + @RequestParam(value = "type", required = false) String type, + @RequestParam(value = "principal", required = false) String principal, + @RequestParam(value = "startDate", required = false) + @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate startDate, + @RequestParam(value = "endDate", required = false) + @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate endDate) { + + // Get data with same filtering as getAuditData + List events; + + if (type != null && principal != null && startDate != null && endDate != null) { + Instant start = startDate.atStartOfDay(ZoneId.systemDefault()).toInstant(); + Instant end = endDate.plusDays(1).atStartOfDay(ZoneId.systemDefault()).toInstant(); + events = auditRepository.findByPrincipalAndTypeAndTimestampBetween( + principal, type, start, end); + } else if (type != null && principal != null) { + events = auditRepository.findByPrincipalAndType(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.findByTypeAndTimestampBetween(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.findByPrincipalAndTimestampBetween(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.findByTimestampBetween(start, end); + } else if (type != null) { + events = auditRepository.findByType(type); + } else if (principal != null) { + events = auditRepository.findByPrincipal(principal); + } else { + events = auditRepository.findAll(); + } + + // Convert to CSV + StringBuilder csv = new StringBuilder(); + csv.append("ID,Principal,Type,Timestamp,Data\n"); + + DateTimeFormatter formatter = DateTimeFormatter.ISO_INSTANT; + + for (PersistentAuditEvent event : events) { + csv.append(event.getId()).append(","); + csv.append(escapeCSV(event.getPrincipal())).append(","); + csv.append(escapeCSV(event.getType())).append(","); + csv.append(formatter.format(event.getTimestamp())).append(","); + csv.append(escapeCSV(event.getData())).append("\n"); + } + + byte[] csvBytes = csv.toString().getBytes(); + + // Set up HTTP headers for download + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.APPLICATION_OCTET_STREAM); + headers.setContentDispositionFormData("attachment", "audit_export.csv"); + + return ResponseEntity.ok() + .headers(headers) + .body(csvBytes); + } + + /** + * Export audit data as JSON. + */ + @GetMapping("/export/json") + public ResponseEntity exportAuditDataJson( + @RequestParam(value = "type", required = false) String type, + @RequestParam(value = "principal", required = false) String principal, + @RequestParam(value = "startDate", required = false) + @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate startDate, + @RequestParam(value = "endDate", required = false) + @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate endDate) { + + // Get data with same filtering as getAuditData + List events; + + if (type != null && principal != null && startDate != null && endDate != null) { + Instant start = startDate.atStartOfDay(ZoneId.systemDefault()).toInstant(); + Instant end = endDate.plusDays(1).atStartOfDay(ZoneId.systemDefault()).toInstant(); + events = auditRepository.findByPrincipalAndTypeAndTimestampBetween( + principal, type, start, end); + } else if (type != null && principal != null) { + events = auditRepository.findByPrincipalAndType(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.findByTypeAndTimestampBetween(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.findByPrincipalAndTimestampBetween(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.findByTimestampBetween(start, end); + } else if (type != null) { + events = auditRepository.findByType(type); + } else if (principal != null) { + events = auditRepository.findByPrincipal(principal); + } else { + events = auditRepository.findAll(); + } + + // Convert to JSON + try { + byte[] jsonBytes = objectMapper.writeValueAsBytes(events); + + // Set up HTTP headers for download + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.APPLICATION_JSON); + headers.setContentDispositionFormData("attachment", "audit_export.json"); + + return ResponseEntity.ok() + .headers(headers) + .body(jsonBytes); + } catch (JsonProcessingException e) { + log.error("Error serializing audit events to JSON", e); + return ResponseEntity.internalServerError().build(); + } + } + + /** + * Helper method to escape CSV fields. + */ + private String escapeCSV(String field) { + if (field == null) { + return ""; + } + // Replace double quotes with two double quotes and wrap in quotes + return "\"" + field.replace("\"", "\"\"") + "\""; + } +} \ No newline at end of file diff --git a/proprietary/src/main/java/stirling/software/proprietary/controller/AuditExampleController.java b/proprietary/src/main/java/stirling/software/proprietary/controller/AuditExampleController.java new file mode 100644 index 000000000..12dd28bda --- /dev/null +++ b/proprietary/src/main/java/stirling/software/proprietary/controller/AuditExampleController.java @@ -0,0 +1,133 @@ +package stirling.software.proprietary.controller; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.multipart.MultipartFile; + +import stirling.software.proprietary.audit.AuditEventType; +import stirling.software.proprietary.audit.AuditLevel; +import stirling.software.proprietary.audit.Audited; +import stirling.software.proprietary.service.AuditService; + +import java.util.HashMap; +import java.util.Map; + +/** + * Example controller showing how to use the audit service. + * This is for demonstration purposes only and should be removed in production. + */ +@Slf4j +@RestController +@RequestMapping("/api/audit-demo") +@RequiredArgsConstructor +public class AuditExampleController { + + private final AuditService auditService; + + /** + * Example using direct AuditService injection + */ + @GetMapping("/manual/{id}") + public String auditManually(@PathVariable String id) { + // Create an example audit event manually + auditService.audit("EXAMPLE_EVENT", Map.of( + "id", id, + "timestamp", System.currentTimeMillis(), + "action", "view" + )); + + return "Audit event created for ID: " + id; + } + + /** + * Example using @Audited annotation with basic level + */ + @Audited(type = AuditEventType.USER_PROFILE_UPDATE, level = AuditLevel.BASIC) + @PostMapping("/users") + public ResponseEntity> createUser(@RequestBody Map user) { + // This method is automatically audited with the USER_REGISTRATION type at BASIC level + + Map result = new HashMap<>(); + result.put("id", "user123"); + result.put("username", user.get("username")); + result.put("created", true); + + return ResponseEntity.ok(result); + } + + /** + * Example using @Audited annotation with file upload at VERBOSE level + */ + @Audited(type = AuditEventType.FILE_DOWNLOAD, level = AuditLevel.VERBOSE, includeResult = true) + @PostMapping("/files/process") + public ResponseEntity> processFile(MultipartFile file) { + // This method is automatically audited at VERBOSE level + // The audit event will include information about the file + // And will also include the result because includeResult=true + + Map result = new HashMap<>(); + result.put("filename", file != null ? file.getOriginalFilename() : "null"); + result.put("size", file != null ? file.getSize() : 0); + result.put("status", "processed"); + + return ResponseEntity.ok(result); + } + + /** + * Automatically audited controller method with GetMapping. + * This method does NOT have an @Audited annotation but will still be + * automatically audited by the ControllerAuditAspect. + */ + @GetMapping("/users/{id}") + public ResponseEntity> getUser(@PathVariable String id) { + // This method will be automatically audited by the ControllerAuditAspect + // The audit will include the controller name, method name, and path + + Map result = new HashMap<>(); + result.put("id", id); + result.put("username", "johndoe"); + result.put("email", "john.doe@example.com"); + + return ResponseEntity.ok(result); + } + + /** + * Automatically audited controller method with PutMapping. + */ + @PutMapping("/users/{id}") + public ResponseEntity> updateUser( + @PathVariable String id, + @RequestBody Map user) { + // This method will be automatically audited by the ControllerAuditAspect + + Map result = new HashMap<>(); + result.put("id", id); + result.put("username", user.get("username")); + result.put("updated", true); + + return ResponseEntity.ok(result); + } + + /** + * Automatically audited controller method with DeleteMapping. + */ + @DeleteMapping("/users/{id}") + public ResponseEntity> deleteUser(@PathVariable String id) { + // This method will be automatically audited by the ControllerAuditAspect + + Map result = new HashMap<>(); + result.put("id", id); + result.put("deleted", true); + + return ResponseEntity.ok(result); + } +} \ No newline at end of file diff --git a/proprietary/src/main/java/stirling/software/proprietary/model/security/PersistentAuditEvent.java b/proprietary/src/main/java/stirling/software/proprietary/model/security/PersistentAuditEvent.java new file mode 100644 index 000000000..1ea2482e1 --- /dev/null +++ b/proprietary/src/main/java/stirling/software/proprietary/model/security/PersistentAuditEvent.java @@ -0,0 +1,24 @@ +package stirling.software.proprietary.model.security; + +import jakarta.persistence.*; +import lombok.*; + +import java.time.Instant; + +@Entity +@Table(name = "audit_events") +@Data @Builder @NoArgsConstructor @AllArgsConstructor +public class PersistentAuditEvent { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + private String principal; + private String type; + + @Lob + private String data; // JSON blob + + private Instant timestamp; +} \ No newline at end of file diff --git a/proprietary/src/main/java/stirling/software/proprietary/repository/PersistentAuditEventRepository.java b/proprietary/src/main/java/stirling/software/proprietary/repository/PersistentAuditEventRepository.java new file mode 100644 index 000000000..ef603b52a --- /dev/null +++ b/proprietary/src/main/java/stirling/software/proprietary/repository/PersistentAuditEventRepository.java @@ -0,0 +1,49 @@ +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.Query; +import org.springframework.stereotype.Repository; + +import stirling.software.proprietary.model.security.PersistentAuditEvent; + +@Repository +public interface PersistentAuditEventRepository + extends JpaRepository { + + // Basic queries + Page findByPrincipal(String principal, Pageable pageable); + Page findByType(String type, Pageable pageable); + Page findByTimestampBetween(Instant startDate, Instant endDate, Pageable pageable); + Page findByPrincipalAndType(String principal, String type, Pageable pageable); + Page findByPrincipalAndTimestampBetween(String principal, Instant startDate, Instant endDate, Pageable pageable); + Page findByTypeAndTimestampBetween(String type, Instant startDate, Instant endDate, Pageable pageable); + Page findByPrincipalAndTypeAndTimestampBetween(String principal, String type, Instant startDate, Instant endDate, Pageable pageable); + + // Non-paged versions for export + List findByPrincipal(String principal); + List findByType(String type); + List findByTimestampBetween(Instant startDate, Instant endDate); + List findByTimestampAfter(Instant startDate); + List findByPrincipalAndType(String principal, String type); + List findByPrincipalAndTimestampBetween(String principal, Instant startDate, Instant endDate); + List findByTypeAndTimestampBetween(String type, Instant startDate, Instant endDate); + List findByPrincipalAndTypeAndTimestampBetween(String principal, String type, Instant startDate, Instant endDate); + + // Cleanup queries + @Query("DELETE FROM PersistentAuditEvent e WHERE e.timestamp < ?1") + @org.springframework.data.jpa.repository.Modifying + @org.springframework.transaction.annotation.Transactional + void deleteByTimestampBefore(Instant cutoffDate); + + // Stats queries + @Query("SELECT e.type, COUNT(e) FROM PersistentAuditEvent e GROUP BY e.type") + List countByType(); + + @Query("SELECT e.principal, COUNT(e) FROM PersistentAuditEvent e GROUP BY e.principal") + List countByPrincipal(); +} \ No newline at end of file diff --git a/proprietary/src/main/java/stirling/software/proprietary/security/CustomAuthenticationFailureHandler.java b/proprietary/src/main/java/stirling/software/proprietary/security/CustomAuthenticationFailureHandler.java index 47ad7671c..d9fe1ff94 100644 --- a/proprietary/src/main/java/stirling/software/proprietary/security/CustomAuthenticationFailureHandler.java +++ b/proprietary/src/main/java/stirling/software/proprietary/security/CustomAuthenticationFailureHandler.java @@ -1,6 +1,8 @@ package stirling.software.proprietary.security; import java.io.IOException; +import java.util.HashMap; +import java.util.Map; import java.util.Optional; import org.springframework.security.authentication.BadCredentialsException; @@ -17,6 +19,9 @@ 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; @@ -35,6 +40,7 @@ public class CustomAuthenticationFailureHandler extends SimpleUrlAuthenticationF } @Override + @Audited(type = AuditEventType.USER_FAILED_LOGIN, level = AuditLevel.BASIC) public void onAuthenticationFailure( HttpServletRequest request, HttpServletResponse response, diff --git a/proprietary/src/main/java/stirling/software/proprietary/security/CustomAuthenticationSuccessHandler.java b/proprietary/src/main/java/stirling/software/proprietary/security/CustomAuthenticationSuccessHandler.java index 8b6ea1dec..4e8df9bb7 100644 --- a/proprietary/src/main/java/stirling/software/proprietary/security/CustomAuthenticationSuccessHandler.java +++ b/proprietary/src/main/java/stirling/software/proprietary/security/CustomAuthenticationSuccessHandler.java @@ -1,6 +1,8 @@ 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; @@ -14,6 +16,9 @@ import jakarta.servlet.http.HttpSession; import lombok.extern.slf4j.Slf4j; 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; @@ -31,6 +36,7 @@ public class CustomAuthenticationSuccessHandler } @Override + @Audited(type = AuditEventType.USER_LOGIN, level = AuditLevel.BASIC) public void onAuthenticationSuccess( HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws ServletException, IOException { diff --git a/proprietary/src/main/java/stirling/software/proprietary/security/CustomLogoutSuccessHandler.java b/proprietary/src/main/java/stirling/software/proprietary/security/CustomLogoutSuccessHandler.java index 77f7ebafd..033ea913c 100644 --- a/proprietary/src/main/java/stirling/software/proprietary/security/CustomLogoutSuccessHandler.java +++ b/proprietary/src/main/java/stirling/software/proprietary/security/CustomLogoutSuccessHandler.java @@ -28,6 +28,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; @@ -42,6 +45,7 @@ public class CustomLogoutSuccessHandler extends SimpleUrlLogoutSuccessHandler { private final AppConfig appConfig; @Override + @Audited(type = AuditEventType.USER_LOGOUT, level = AuditLevel.BASIC) public void onLogoutSuccess( HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException { diff --git a/proprietary/src/main/java/stirling/software/proprietary/service/AuditCleanupService.java b/proprietary/src/main/java/stirling/software/proprietary/service/AuditCleanupService.java new file mode 100644 index 000000000..fafcb52e4 --- /dev/null +++ b/proprietary/src/main/java/stirling/software/proprietary/service/AuditCleanupService.java @@ -0,0 +1,53 @@ +package stirling.software.proprietary.service; + +import java.time.Instant; +import java.time.temporal.ChronoUnit; +import java.util.concurrent.TimeUnit; + +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Service; + +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; + + /** + * 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()) { + log.debug("Audit system is disabled, skipping cleanup"); + return; + } + + int retentionDays = auditConfig.getRetentionDays(); + if (retentionDays <= 0) { + log.info("Audit retention is set to {} days, no cleanup needed", retentionDays); + return; + } + + log.info("Starting audit cleanup for events older than {} days", retentionDays); + + try { + Instant cutoffDate = Instant.now().minus(retentionDays, ChronoUnit.DAYS); + auditRepository.deleteByTimestampBefore(cutoffDate); + log.info("Successfully cleaned up audit events older than {}", cutoffDate); + } catch (Exception e) { + log.error("Error cleaning up old audit events", e); + } + } +} \ No newline at end of file diff --git a/proprietary/src/main/java/stirling/software/proprietary/service/AuditService.java b/proprietary/src/main/java/stirling/software/proprietary/service/AuditService.java new file mode 100644 index 000000000..0df667d4c --- /dev/null +++ b/proprietary/src/main/java/stirling/software/proprietary/service/AuditService.java @@ -0,0 +1,157 @@ +package stirling.software.proprietary.service; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +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 +@RequiredArgsConstructor +public class AuditService { + + private final AuditEventRepository repository; + private final AuditConfigurationProperties auditConfig; + + /** + * Record an audit event for the current authenticated user with a specific audit level + * using the standardized AuditEventType enum + * + * @param type The event type from AuditEventType enum + * @param data Additional event data (will be automatically sanitized) + * @param level The minimum audit level required for this event to be logged + */ + public void audit(AuditEventType type, Map data, AuditLevel level) { + // Skip auditing if this level is not enabled + if (!auditConfig.isLevelEnabled(level)) { + return; + } + + String principal = getCurrentUsername(); + repository.add(new AuditEvent(principal, type.name(), data)); + } + + /** + * Record an audit event for the current authenticated user with standard level + * using the standardized AuditEventType enum + * + * @param type The event type from AuditEventType enum + * @param data Additional event data (will be automatically sanitized) + */ + public void audit(AuditEventType type, Map data) { + // Default to STANDARD level + audit(type, data, AuditLevel.STANDARD); + } + + /** + * Record an audit event for a specific user with a specific audit level + * using the standardized AuditEventType enum + * + * @param principal The username or system identifier + * @param type The event type from AuditEventType enum + * @param data Additional event data (will be automatically sanitized) + * @param level The minimum audit level required for this event to be logged + */ + public void audit(String principal, AuditEventType type, Map data, AuditLevel level) { + // Skip auditing if this level is not enabled + if (!auditConfig.isLevelEnabled(level)) { + return; + } + + repository.add(new AuditEvent(principal, type.name(), data)); + } + + /** + * Record an audit event for a specific user with standard level + * using the standardized AuditEventType enum + * + * @param principal The username or system identifier + * @param type The event type from AuditEventType enum + * @param data Additional event data (will be automatically sanitized) + */ + public void audit(String principal, AuditEventType type, Map data) { + // Default to STANDARD level + audit(principal, type, data, AuditLevel.STANDARD); + } + + /** + * Record an audit event for the current authenticated user with a specific audit level + * using a string-based event type (for backward compatibility) + * + * @param type The event type (e.g., "FILE_UPLOAD", "PASSWORD_CHANGE") + * @param data Additional event data (will be automatically sanitized) + * @param level The minimum audit level required for this event to be logged + */ + public void audit(String type, Map data, AuditLevel level) { + // Skip auditing if this level is not enabled + if (!auditConfig.isLevelEnabled(level)) { + return; + } + + String principal = getCurrentUsername(); + repository.add(new AuditEvent(principal, type, data)); + } + + /** + * Record an audit event for the current authenticated user with standard level + * using a string-based event type (for backward compatibility) + * + * @param type The event type (e.g., "FILE_UPLOAD", "PASSWORD_CHANGE") + * @param data Additional event data (will be automatically sanitized) + */ + public void audit(String type, Map data) { + // Default to STANDARD level + audit(type, data, AuditLevel.STANDARD); + } + + /** + * Record an audit event for a specific user with a specific audit level + * using a string-based event type (for backward compatibility) + * + * @param principal The username or system identifier + * @param type The event type (e.g., "FILE_UPLOAD", "PASSWORD_CHANGE") + * @param data Additional event data (will be automatically sanitized) + * @param level The minimum audit level required for this event to be logged + */ + public void audit(String principal, String type, Map data, AuditLevel level) { + // Skip auditing if this level is not enabled + if (!auditConfig.isLevelEnabled(level)) { + return; + } + + repository.add(new AuditEvent(principal, type, data)); + } + + /** + * Record an audit event for a specific user with standard level + * using a string-based event type (for backward compatibility) + * + * @param principal The username or system identifier + * @param type The event type (e.g., "FILE_UPLOAD", "PASSWORD_CHANGE") + * @param data Additional event data (will be automatically sanitized) + */ + public void audit(String principal, String type, Map data) { + // Default to STANDARD level + audit(principal, type, data, AuditLevel.STANDARD); + } + + /** + * Get the current authenticated username or "system" if none + */ + private String getCurrentUsername() { + Authentication auth = SecurityContextHolder.getContext().getAuthentication(); + return (auth != null && auth.getName() != null) ? auth.getName() : "system"; + } +} \ No newline at end of file diff --git a/proprietary/src/main/java/stirling/software/proprietary/util/SecretMasker.java b/proprietary/src/main/java/stirling/software/proprietary/util/SecretMasker.java new file mode 100644 index 000000000..b0d1fb283 --- /dev/null +++ b/proprietary/src/main/java/stirling/software/proprietary/util/SecretMasker.java @@ -0,0 +1,34 @@ +package stirling.software.proprietary.util; + +import java.util.HashMap; +import java.util.Map; +import java.util.regex.Pattern; +import java.util.stream.Collectors; + +/** Redacts any map values whose keys match common secret/token patterns. */ +public final class SecretMasker { + + private static final Pattern SENSITIVE = + Pattern.compile("(?i)(password|token|secret|api[_-]?key|authorization|auth)"); + + private SecretMasker() {} + + public static Map mask(Map in) { + if (in == null) { + return null; + } + + Map result = new HashMap<>(in.size()); + + for (Map.Entry entry : in.entrySet()) { + String key = entry.getKey(); + if (key != null && SENSITIVE.matcher(key).find()) { + result.put(key, "***REDACTED***"); + } else { + result.put(key, entry.getValue()); + } + } + + return result; + } +} \ No newline at end of file diff --git a/proprietary/src/main/java/stirling/software/proprietary/web/AuditWebFilter.java b/proprietary/src/main/java/stirling/software/proprietary/web/AuditWebFilter.java new file mode 100644 index 000000000..a7dd6ea66 --- /dev/null +++ b/proprietary/src/main/java/stirling/software/proprietary/web/AuditWebFilter.java @@ -0,0 +1,97 @@ +package stirling.software.proprietary.web; + +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.slf4j.MDC; +import org.springframework.core.Ordered; +import org.springframework.core.annotation.Order; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.stereotype.Component; +import org.springframework.web.filter.OncePerRequestFilter; +import org.springframework.web.util.ContentCachingRequestWrapper; +import org.springframework.web.util.ContentCachingResponseWrapper; + +import java.io.IOException; +import java.util.Collections; +import java.util.Enumeration; +import java.util.HashMap; +import java.util.Map; + +/** + * Filter that stores additional request information for audit purposes + */ +@Slf4j +@Component +@Order(Ordered.HIGHEST_PRECEDENCE + 10) +@RequiredArgsConstructor +public class AuditWebFilter extends OncePerRequestFilter { + + private static final String USER_AGENT_HEADER = "User-Agent"; + private static final String REFERER_HEADER = "Referer"; + private static final String ACCEPT_LANGUAGE_HEADER = "Accept-Language"; + private static final String CONTENT_TYPE_HEADER = "Content-Type"; + + @Override + protected void doFilterInternal(HttpServletRequest request, + HttpServletResponse response, + FilterChain filterChain) throws ServletException, IOException { + + // Store key request info in MDC for logging and later audit use + try { + // Store request headers + String userAgent = request.getHeader(USER_AGENT_HEADER); + if (userAgent != null) { + MDC.put("userAgent", userAgent); + } + + String referer = request.getHeader(REFERER_HEADER); + if (referer != null) { + MDC.put("referer", referer); + } + + String acceptLanguage = request.getHeader(ACCEPT_LANGUAGE_HEADER); + if (acceptLanguage != null) { + MDC.put("acceptLanguage", acceptLanguage); + } + + String contentType = request.getHeader(CONTENT_TYPE_HEADER); + if (contentType != null) { + MDC.put("contentType", contentType); + } + + // Store authenticated user roles if available + Authentication auth = SecurityContextHolder.getContext().getAuthentication(); + if (auth != null && auth.getAuthorities() != null) { + String roles = auth.getAuthorities().stream() + .map(a -> a.getAuthority()) + .reduce((a, b) -> a + "," + b) + .orElse(""); + MDC.put("userRoles", roles); + } + + // Store query parameters (without values for privacy) + Map parameterMap = request.getParameterMap(); + if (parameterMap != null && !parameterMap.isEmpty()) { + String params = String.join(",", parameterMap.keySet()); + MDC.put("queryParams", params); + } + + // Continue with the filter chain + filterChain.doFilter(request, response); + + } finally { + // Clear MDC after request is processed + MDC.remove("userAgent"); + MDC.remove("referer"); + MDC.remove("acceptLanguage"); + MDC.remove("contentType"); + MDC.remove("userRoles"); + MDC.remove("queryParams"); + } + } +} \ No newline at end of file diff --git a/proprietary/src/main/java/stirling/software/proprietary/web/CorrelationIdFilter.java b/proprietary/src/main/java/stirling/software/proprietary/web/CorrelationIdFilter.java new file mode 100644 index 000000000..9dc032dfe --- /dev/null +++ b/proprietary/src/main/java/stirling/software/proprietary/web/CorrelationIdFilter.java @@ -0,0 +1,46 @@ +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.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, id); + + chain.doFilter(req, res); + } finally { + MDC.remove(MDC_KEY); + } + } +} \ No newline at end of file diff --git a/proprietary/src/main/resources/application-proprietary.properties b/proprietary/src/main/resources/application-proprietary.properties new file mode 100644 index 000000000..76309521c --- /dev/null +++ b/proprietary/src/main/resources/application-proprietary.properties @@ -0,0 +1,12 @@ +# ── Actuator surface-area hardening ─────────────────────── +# Enable Prometheus metrics endpoint +management.endpoints.web.exposure.include=health,info,metrics,prometheus +# Exclude auditevents from exposure +management.endpoints.web.exposure.exclude=auditevents +# Disable the audit events endpoint completely +management.endpoint.auditevents.enabled=false +# Configure endpoints +management.endpoints.web.base-path=/actuator +management.info.env.enabled=true +management.endpoint.health.show-details=when_authorized +management.endpoint.health.roles=ADMIN \ No newline at end of file diff --git a/proprietary/src/main/resources/templates/AUDIT_USAGE.md b/proprietary/src/main/resources/templates/AUDIT_USAGE.md new file mode 100644 index 000000000..57cdce61b --- /dev/null +++ b/proprietary/src/main/resources/templates/AUDIT_USAGE.md @@ -0,0 +1,250 @@ +# Stirling PDF Audit System + +This document provides guidance on how to use the audit system in Stirling PDF. + +## Overview + +The audit system provides comprehensive logging of user actions and system events, storing them in a database for later review. This is useful for: + +- Security monitoring +- Compliance requirements +- User activity tracking +- Troubleshooting + +## Audit Levels + +The audit system supports different levels of detail that can be configured in the settings.yml file: + +### Level 0: OFF +- Disables all audit logging except for critical security events +- Minimal database usage and performance impact +- Only recommended for development environments + +### Level 1: BASIC +- Authentication events (login, logout, failed logins) +- Password changes +- User/role changes +- System configuration changes +- HTTP request errors (status codes >= 400) + +### Level 2: STANDARD (Default) +- Everything in BASIC plus: +- All HTTP requests (basic info: URL, method, status) +- File operations (upload, download, process) +- PDF operations (view, edit, etc.) +- User operations + +### Level 3: VERBOSE +- Everything in STANDARD plus: +- Request headers and parameters +- Method parameters +- Operation results +- Detailed timing information + +## Configuration + +Audit levels are configured in the settings.yml file under the premium section: + +```yaml +premium: + proFeatures: + audit: + enabled: true # Enable/disable audit logging + level: 2 # Audit level (0=OFF, 1=BASIC, 2=STANDARD, 3=VERBOSE) + retentionDays: 90 # Number of days to retain audit logs +``` + +## Automatic Auditing + +The following events are automatically audited (based on configured level): + +### HTTP Request Auditing +All HTTP requests are automatically audited with details based on the configured level: + +- **BASIC level**: Only errors (status code >= 400) +- **STANDARD level**: All requests with basic information (URL, method, status code, latency, IP) +- **VERBOSE level**: All of the above plus headers, parameters, and detailed timing + +### Controller Method Auditing +All controller methods with web mapping annotations are automatically audited: + +- `@GetMapping` +- `@PostMapping` +- `@PutMapping` +- `@DeleteMapping` +- `@PatchMapping` + +Methods with these annotations are audited at the **STANDARD** level by default. + +### Security Events +The following security events are always audited at the **BASIC** level: + +- Authentication events (login, logout, failed login attempts) +- Password changes +- User/role changes + +## Manual Auditing + +There are two ways to add audit events from your code: + +### 1. Using AuditService Directly + +Inject the `AuditService` and use it directly: + +```java +@Service +@RequiredArgsConstructor +public class MyService { + + private final AuditService auditService; + + public void processPdf(MultipartFile file) { + // Process the file... + + // Add an audit event with default level (STANDARD) + auditService.audit("PDF_PROCESSED", Map.of( + "filename", file.getOriginalFilename(), + "size", file.getSize(), + "operation", "process" + )); + + // Or specify an audit level + auditService.audit("PDF_PROCESSED_DETAILED", Map.of( + "filename", file.getOriginalFilename(), + "size", file.getSize(), + "operation", "process", + "metadata", file.getContentType(), + "user", "johndoe" + ), AuditLevel.VERBOSE); + + // Critical security events should use BASIC level to ensure they're always logged + auditService.audit("SECURITY_EVENT", Map.of( + "action", "file_access", + "resource", file.getOriginalFilename() + ), AuditLevel.BASIC); + } +} +``` + +### 2. Using the @Audited Annotation + +For simpler auditing, use the `@Audited` annotation on your methods: + +```java +@Service +public class UserService { + + // Basic audit level for important security events + @Audited(type = "USER_REGISTRATION", level = AuditLevel.BASIC) + public User registerUser(String username, String email) { + // Method implementation + User user = new User(username, email); + // Save user... + return user; + } + + // Sensitive operations should use BASIC but disable argument logging + @Audited(type = "USER_PASSWORD_CHANGE", level = AuditLevel.BASIC, includeArgs = false) + public void changePassword(String username, String newPassword) { + // Change password implementation + // includeArgs=false prevents the password from being included in the audit + } + + // Standard level for normal operations (default) + @Audited(type = "USER_LOGIN") + public boolean login(String username, String password) { + // Login implementation + return true; + } + + // Verbose level for detailed information + @Audited(type = "USER_SEARCH", level = AuditLevel.VERBOSE, includeResult = true) + public List searchUsers(String query) { + // Search implementation + // At VERBOSE level, this will include both the query and results + return userList; + } +} +``` + +With the `@Audited` annotation: +- You can specify the audit level using the `level` parameter +- Method arguments are automatically included in the audit event (unless `includeArgs = false`) +- Return values can be included with `includeResult = true` +- Exceptions are automatically captured and included in the audit +- The aspect handles all the boilerplate code for you +- The annotation respects the configured global audit level + +### 3. Controller Automatic Auditing + +In addition to the manual methods above, all controller methods with web mapping annotations are automatically audited, even without the `@Audited` annotation: + +```java +@RestController +@RequestMapping("/api/users") +public class UserController { + + // This method will be automatically audited + @GetMapping("/{id}") + public ResponseEntity getUser(@PathVariable String id) { + // Method implementation + return ResponseEntity.ok(user); + } + + // This method will be automatically audited + @PostMapping + public ResponseEntity createUser(@RequestBody User user) { + // Method implementation + return ResponseEntity.ok(savedUser); + } + + // This method uses @Audited and takes precedence over automatic auditing + @Audited(type = "USER_DELETE", level = AuditLevel.BASIC) + @DeleteMapping("/{id}") + public ResponseEntity deleteUser(@PathVariable String id) { + // Method implementation + return ResponseEntity.noContent().build(); + } +} +``` + +Important notes about automatic controller auditing: +- All controller methods with web mapping annotations are audited at the STANDARD level +- If a method already has an @Audited annotation, that takes precedence +- The audit event includes controller name, method name, path, and HTTP method +- At VERBOSE level, request parameters are also included +- Exceptions are automatically captured + +## Common Audit Event Types + +Use consistent event types throughout the application: + +- `FILE_UPLOAD` - When a file is uploaded +- `FILE_DOWNLOAD` - When a file is downloaded +- `PDF_PROCESS` - When a PDF is processed (split, merged, etc.) +- `USER_CREATE` - When a user is created +- `USER_UPDATE` - When a user details are updated +- `PASSWORD_CHANGE` - When a password is changed +- `PERMISSION_CHANGE` - When permissions are modified +- `SETTINGS_CHANGE` - When system settings are changed + +## Security Considerations + +- Sensitive data is automatically masked in audit logs (passwords, API keys, tokens) +- Each audit event includes a unique request ID for correlation +- Audit events are stored asynchronously to avoid performance impact +- The `/auditevents` endpoint is disabled to prevent unauthorized access to audit data + +## Database Storage + +Audit events are stored in the `audit_events` table with the following schema: + +- `id` - Unique identifier +- `principal` - The username or system identifier +- `type` - The event type +- `data` - JSON blob containing event details +- `timestamp` - When the event occurred + +## Metrics + +Prometheus metrics are available at `/actuator/prometheus` for monitoring system performance and audit event volume. \ No newline at end of file diff --git a/proprietary/src/main/resources/templates/audit/dashboard.html b/proprietary/src/main/resources/templates/audit/dashboard.html new file mode 100644 index 000000000..40bb85079 --- /dev/null +++ b/proprietary/src/main/resources/templates/audit/dashboard.html @@ -0,0 +1,947 @@ + + + + + + + + + + + + +
+
+ + +
+

Audit Dashboard

+ + +
+
+

Audit System Status

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

Events by Type

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

Events by User

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

Events Over Time

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

Audit Events

+
+
+ +
+
+
+
+ + +
+
+
+
+ + +
+
+
+
+ + +
+
+
+
+ + +
+
+
+
+
+ + +
+
+
+ + +
+
+
+ Loading... +
+
+ + + + + + + + + + + + + +
IDTimeUserTypeDetails
+
+ + +
+
+ Show + + entries +
+ +
+
+
+ + + +
+ + +
+
+
+

Export Audit Data

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

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

+

Exported data will include:

+
    +
  • Event ID
  • +
  • User
  • +
  • Event Type
  • +
  • Timestamp
  • +
  • Event Data
  • +
+
+
+
+
+ + +
+
+
+

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

+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
LevelNameDescriptionUse Case
0OFFMinimal auditing, only critical security eventsDevelopment environments
1BASICAuthentication events, security events, and errorsProduction environments with minimal storage
2STANDARDAll HTTP requests and operations (default)Normal production use
3VERBOSEDetailed information including headers, parameters, and resultsTroubleshooting and detailed analysis
+
+ +

Configuration

+

Audit settings are configured in the settings.yml file under the premium.proFeatures.audit section:

+
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_UPLOAD - File uploads
    • +
    • FILE_DOWNLOAD - File downloads
    • +
    • SETTINGS_CHANGED - System or admin settings operations
    • +
    +
  • +
  • + VERBOSE Events: +
      +
    • Detailed versions of STANDARD events with parameters and results
    • +
    +
  • +
    +
+
+
+
+
+
+ + + + + + +
+
+ + + + \ No newline at end of file diff --git a/stirling-pdf/src/main/resources/settings.yml.template b/stirling-pdf/src/main/resources/settings.yml.template index e786b9080..b92c6bad0 100644 --- a/stirling-pdf/src/main/resources/settings.yml.template +++ b/stirling-pdf/src/main/resources/settings.yml.template @@ -66,6 +66,10 @@ premium: proFeatures: database: true # Enable database features SSOAutoLogin: false + 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 CustomMetadata: autoUpdateMetadata: false author: username diff --git a/stirling-pdf/src/main/resources/templates/adminSettings.html b/stirling-pdf/src/main/resources/templates/adminSettings.html index 2cf5e585b..572d24631 100644 --- a/stirling-pdf/src/main/resources/templates/adminSettings.html +++ b/stirling-pdf/src/main/resources/templates/adminSettings.html @@ -119,6 +119,11 @@ analytics Usage Statistics + + + security + Audit Dashboard +