mirror of
https://github.com/Stirling-Tools/Stirling-PDF.git
synced 2025-06-23 16:05:09 +00:00
audit init
This commit is contained in:
parent
3c507eb303
commit
a3ccc677b1
@ -442,8 +442,17 @@ public class ApplicationProperties {
|
|||||||
@Data
|
@Data
|
||||||
public static class ProFeatures {
|
public static class ProFeatures {
|
||||||
private boolean ssoAutoLogin;
|
private boolean ssoAutoLogin;
|
||||||
|
private boolean database;
|
||||||
private CustomMetadata customMetadata = new CustomMetadata();
|
private CustomMetadata customMetadata = new CustomMetadata();
|
||||||
private GoogleDrive googleDrive = new GoogleDrive();
|
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
|
@Data
|
||||||
public static class CustomMetadata {
|
public static class CustomMetadata {
|
||||||
|
58
convert_properties_to_utf8.sh
Normal file
58
convert_properties_to_utf8.sh
Normal file
@ -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."
|
@ -18,6 +18,7 @@ dependencies {
|
|||||||
api 'org.springframework.boot:spring-boot-starter-oauth2-client'
|
api 'org.springframework.boot:spring-boot-starter-oauth2-client'
|
||||||
api 'org.springframework.boot:spring-boot-starter-mail'
|
api 'org.springframework.boot:spring-boot-starter-mail'
|
||||||
api 'io.swagger.core.v3:swagger-core-jakarta:2.2.30'
|
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'
|
implementation 'com.bucket4j:bucket4j_jdk17-core:8.14.0'
|
||||||
|
|
||||||
// https://mvnrepository.com/artifact/com.bucket4j/bucket4j_jdk17
|
// https://mvnrepository.com/artifact/com.bucket4j/bucket4j_jdk17
|
||||||
|
@ -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<String, Object> 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());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
@ -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,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<String, Object> 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;
|
||||||
|
}
|
||||||
|
}
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
@ -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,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<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());
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -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<ServletRequestHandledEvent> {
|
||||||
|
|
||||||
|
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<String, Object> 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<String, Object> 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";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -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<String, Object> 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<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.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<String, Object> 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<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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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.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<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.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("\"", "\"\"") + "\"";
|
||||||
|
}
|
||||||
|
}
|
@ -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<Map<String, Object>> createUser(@RequestBody Map<String, Object> user) {
|
||||||
|
// This method is automatically audited with the USER_REGISTRATION type at BASIC level
|
||||||
|
|
||||||
|
Map<String, Object> 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<Map<String, Object>> 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<String, Object> 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<Map<String, Object>> 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<String, Object> 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<Map<String, Object>> updateUser(
|
||||||
|
@PathVariable String id,
|
||||||
|
@RequestBody Map<String, Object> user) {
|
||||||
|
// This method will be automatically audited by the ControllerAuditAspect
|
||||||
|
|
||||||
|
Map<String, Object> 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<Map<String, Object>> deleteUser(@PathVariable String id) {
|
||||||
|
// This method will be automatically audited by the ControllerAuditAspect
|
||||||
|
|
||||||
|
Map<String, Object> result = new HashMap<>();
|
||||||
|
result.put("id", id);
|
||||||
|
result.put("deleted", true);
|
||||||
|
|
||||||
|
return ResponseEntity.ok(result);
|
||||||
|
}
|
||||||
|
}
|
@ -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;
|
||||||
|
}
|
@ -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<PersistentAuditEvent, Long> {
|
||||||
|
|
||||||
|
// Basic queries
|
||||||
|
Page<PersistentAuditEvent> findByPrincipal(String principal, Pageable pageable);
|
||||||
|
Page<PersistentAuditEvent> findByType(String type, Pageable pageable);
|
||||||
|
Page<PersistentAuditEvent> findByTimestampBetween(Instant startDate, Instant endDate, Pageable pageable);
|
||||||
|
Page<PersistentAuditEvent> findByPrincipalAndType(String principal, String type, Pageable pageable);
|
||||||
|
Page<PersistentAuditEvent> findByPrincipalAndTimestampBetween(String principal, Instant startDate, Instant endDate, Pageable pageable);
|
||||||
|
Page<PersistentAuditEvent> findByTypeAndTimestampBetween(String type, Instant startDate, Instant endDate, Pageable pageable);
|
||||||
|
Page<PersistentAuditEvent> findByPrincipalAndTypeAndTimestampBetween(String principal, String type, Instant startDate, Instant endDate, Pageable pageable);
|
||||||
|
|
||||||
|
// Non-paged versions for export
|
||||||
|
List<PersistentAuditEvent> findByPrincipal(String principal);
|
||||||
|
List<PersistentAuditEvent> findByType(String type);
|
||||||
|
List<PersistentAuditEvent> findByTimestampBetween(Instant startDate, Instant endDate);
|
||||||
|
List<PersistentAuditEvent> findByTimestampAfter(Instant startDate);
|
||||||
|
List<PersistentAuditEvent> findByPrincipalAndType(String principal, String type);
|
||||||
|
List<PersistentAuditEvent> findByPrincipalAndTimestampBetween(String principal, Instant startDate, Instant endDate);
|
||||||
|
List<PersistentAuditEvent> findByTypeAndTimestampBetween(String type, Instant startDate, Instant endDate);
|
||||||
|
List<PersistentAuditEvent> 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<Object[]> countByType();
|
||||||
|
|
||||||
|
@Query("SELECT e.principal, COUNT(e) FROM PersistentAuditEvent e GROUP BY e.principal")
|
||||||
|
List<Object[]> countByPrincipal();
|
||||||
|
}
|
@ -1,6 +1,8 @@
|
|||||||
package stirling.software.proprietary.security;
|
package stirling.software.proprietary.security;
|
||||||
|
|
||||||
import java.io.IOException;
|
import java.io.IOException;
|
||||||
|
import java.util.HashMap;
|
||||||
|
import java.util.Map;
|
||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
|
|
||||||
import org.springframework.security.authentication.BadCredentialsException;
|
import org.springframework.security.authentication.BadCredentialsException;
|
||||||
@ -17,6 +19,9 @@ import jakarta.servlet.http.HttpServletResponse;
|
|||||||
|
|
||||||
import lombok.extern.slf4j.Slf4j;
|
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.model.User;
|
||||||
import stirling.software.proprietary.security.service.LoginAttemptService;
|
import stirling.software.proprietary.security.service.LoginAttemptService;
|
||||||
import stirling.software.proprietary.security.service.UserService;
|
import stirling.software.proprietary.security.service.UserService;
|
||||||
@ -35,6 +40,7 @@ public class CustomAuthenticationFailureHandler extends SimpleUrlAuthenticationF
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
|
@Audited(type = AuditEventType.USER_FAILED_LOGIN, level = AuditLevel.BASIC)
|
||||||
public void onAuthenticationFailure(
|
public void onAuthenticationFailure(
|
||||||
HttpServletRequest request,
|
HttpServletRequest request,
|
||||||
HttpServletResponse response,
|
HttpServletResponse response,
|
||||||
|
@ -1,6 +1,8 @@
|
|||||||
package stirling.software.proprietary.security;
|
package stirling.software.proprietary.security;
|
||||||
|
|
||||||
import java.io.IOException;
|
import java.io.IOException;
|
||||||
|
import java.util.HashMap;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
import org.springframework.security.core.Authentication;
|
import org.springframework.security.core.Authentication;
|
||||||
import org.springframework.security.web.authentication.SavedRequestAwareAuthenticationSuccessHandler;
|
import org.springframework.security.web.authentication.SavedRequestAwareAuthenticationSuccessHandler;
|
||||||
@ -14,6 +16,9 @@ import jakarta.servlet.http.HttpSession;
|
|||||||
import lombok.extern.slf4j.Slf4j;
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
|
||||||
import stirling.software.common.util.RequestUriUtils;
|
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.LoginAttemptService;
|
||||||
import stirling.software.proprietary.security.service.UserService;
|
import stirling.software.proprietary.security.service.UserService;
|
||||||
|
|
||||||
@ -31,6 +36,7 @@ public class CustomAuthenticationSuccessHandler
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
|
@Audited(type = AuditEventType.USER_LOGIN, level = AuditLevel.BASIC)
|
||||||
public void onAuthenticationSuccess(
|
public void onAuthenticationSuccess(
|
||||||
HttpServletRequest request, HttpServletResponse response, Authentication authentication)
|
HttpServletRequest request, HttpServletResponse response, Authentication authentication)
|
||||||
throws ServletException, IOException {
|
throws ServletException, IOException {
|
||||||
|
@ -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.ApplicationProperties.Security.SAML2;
|
||||||
import stirling.software.common.model.oauth2.KeycloakProvider;
|
import stirling.software.common.model.oauth2.KeycloakProvider;
|
||||||
import stirling.software.common.util.UrlUtils;
|
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.CertificateUtils;
|
||||||
import stirling.software.proprietary.security.saml2.CustomSaml2AuthenticatedPrincipal;
|
import stirling.software.proprietary.security.saml2.CustomSaml2AuthenticatedPrincipal;
|
||||||
|
|
||||||
@ -42,6 +45,7 @@ public class CustomLogoutSuccessHandler extends SimpleUrlLogoutSuccessHandler {
|
|||||||
private final AppConfig appConfig;
|
private final AppConfig appConfig;
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
|
@Audited(type = AuditEventType.USER_LOGOUT, level = AuditLevel.BASIC)
|
||||||
public void onLogoutSuccess(
|
public void onLogoutSuccess(
|
||||||
HttpServletRequest request, HttpServletResponse response, Authentication authentication)
|
HttpServletRequest request, HttpServletResponse response, Authentication authentication)
|
||||||
throws IOException {
|
throws IOException {
|
||||||
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -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<String, Object> 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<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
|
||||||
|
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<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
|
||||||
|
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<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
|
||||||
|
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<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,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<String, Object> mask(Map<String, Object> in) {
|
||||||
|
if (in == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<String, Object> result = new HashMap<>(in.size());
|
||||||
|
|
||||||
|
for (Map.Entry<String, Object> 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;
|
||||||
|
}
|
||||||
|
}
|
@ -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,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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -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
|
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.
|
947
proprietary/src/main/resources/templates/audit/dashboard.html
Normal file
947
proprietary/src/main/resources/templates/audit/dashboard.html
Normal file
@ -0,0 +1,947 @@
|
|||||||
|
<!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>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.dashboard-card {
|
||||||
|
margin-bottom: 20px;
|
||||||
|
border-radius: 8px;
|
||||||
|
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
|
||||||
|
}
|
||||||
|
.stat-card {
|
||||||
|
text-align: center;
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
.stat-number {
|
||||||
|
font-size: 2rem;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
.stat-label {
|
||||||
|
font-size: 1rem;
|
||||||
|
color: #666;
|
||||||
|
}
|
||||||
|
.chart-container {
|
||||||
|
position: relative;
|
||||||
|
height: 300px;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
.filter-card {
|
||||||
|
margin-bottom: 20px;
|
||||||
|
padding: 15px;
|
||||||
|
}
|
||||||
|
.loading-overlay {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
background-color: rgba(255, 255, 255, 0.7);
|
||||||
|
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: #dc3545; /* Red */
|
||||||
|
}
|
||||||
|
.level-1 {
|
||||||
|
background-color: #fd7e14; /* Orange */
|
||||||
|
}
|
||||||
|
.level-2 {
|
||||||
|
background-color: #28a745; /* Green */
|
||||||
|
}
|
||||||
|
.level-3 {
|
||||||
|
background-color: #17a2b8; /* Teal */
|
||||||
|
}
|
||||||
|
/* Custom data table styling */
|
||||||
|
.audit-table {
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
.audit-table th {
|
||||||
|
background-color: #f8f9fa;
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
z-index: 10;
|
||||||
|
}
|
||||||
|
.table-responsive {
|
||||||
|
max-height: 600px;
|
||||||
|
}
|
||||||
|
.pagination-container {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-top: 10px;
|
||||||
|
}
|
||||||
|
.json-viewer {
|
||||||
|
background-color: #f8f9fa;
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 10px;
|
||||||
|
max-height: 300px;
|
||||||
|
overflow-y: auto;
|
||||||
|
font-family: monospace;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<th:block th:insert="~{fragments/common :: game}"></th:block>
|
||||||
|
<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">Audit Dashboard</h1>
|
||||||
|
|
||||||
|
<!-- System Status Card -->
|
||||||
|
<div class="card dashboard-card mb-4">
|
||||||
|
<div class="card-header">
|
||||||
|
<h2 class="h5 mb-0">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">Status</div>
|
||||||
|
<div class="stat-number">
|
||||||
|
<span th:if="${auditEnabled}" class="text-success">Enabled</span>
|
||||||
|
<span th:unless="${auditEnabled}" class="text-danger">Disabled</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-3">
|
||||||
|
<div class="stat-card">
|
||||||
|
<div class="stat-label">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">Retention Period</div>
|
||||||
|
<div class="stat-number" th:text="${retentionDays} + ' days'">90 days</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-3">
|
||||||
|
<div class="stat-card">
|
||||||
|
<div class="stat-label">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">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">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">Export</button>
|
||||||
|
</li>
|
||||||
|
<li class="nav-item" role="presentation">
|
||||||
|
<button class="nav-link" id="help-tab" data-bs-toggle="tab" data-bs-target="#help" type="button" role="tab" aria-controls="help" aria-selected="false">Help</button>
|
||||||
|
</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">Events by Type</h3>
|
||||||
|
<div class="btn-group">
|
||||||
|
<button class="btn btn-sm btn-outline-secondary" onclick="loadStats(7)">7 Days</button>
|
||||||
|
<button class="btn btn-sm btn-outline-secondary" onclick="loadStats(30)">30 Days</button>
|
||||||
|
<button class="btn btn-sm btn-outline-secondary" onclick="loadStats(90)">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">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">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">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">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">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">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">Event Type</label>
|
||||||
|
<input type="text" class="form-control" id="typeFilter" placeholder="Filter by type">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-3">
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="principalFilter" class="form-label">User</label>
|
||||||
|
<input type="text" class="form-control" id="principalFilter" placeholder="Filter by user">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-3">
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="startDateFilter" class="form-label">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">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">Apply Filters</button>
|
||||||
|
<button id="resetFilters" class="btn btn-secondary">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">Loading...</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<table class="table table-striped table-hover audit-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>ID</th>
|
||||||
|
<th>Time</th>
|
||||||
|
<th>User</th>
|
||||||
|
<th>Type</th>
|
||||||
|
<th>Details</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody id="auditTableBody">
|
||||||
|
<!-- Table rows will be populated by JavaScript -->
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Pagination -->
|
||||||
|
<div class="pagination-container">
|
||||||
|
<div>
|
||||||
|
<span>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>entries</span>
|
||||||
|
</div>
|
||||||
|
<nav aria-label="Audit events pagination">
|
||||||
|
<ul class="pagination" id="pagination">
|
||||||
|
<!-- Pagination will be populated by JavaScript -->
|
||||||
|
</ul>
|
||||||
|
</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">
|
||||||
|
<div class="modal-content">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h5 class="modal-title" id="eventDetailsModalLabel">Event Details</h5>
|
||||||
|
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<div class="row mb-3">
|
||||||
|
<div class="col-md-4">
|
||||||
|
<strong>ID:</strong> <span id="modal-id"></span>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-4">
|
||||||
|
<strong>User:</strong> <span id="modal-principal"></span>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-4">
|
||||||
|
<strong>Type:</strong> <span id="modal-type"></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="row mb-3">
|
||||||
|
<div class="col-md-12">
|
||||||
|
<strong>Time:</strong> <span id="modal-timestamp"></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-md-12">
|
||||||
|
<strong>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">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">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">Event Type</label>
|
||||||
|
<input type="text" class="form-control" id="exportTypeFilter" placeholder="Filter by type">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-3">
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="exportPrincipalFilter" class="form-label">User</label>
|
||||||
|
<input type="text" class="form-control" id="exportPrincipalFilter" placeholder="Filter by user">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-3">
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="exportStartDateFilter" class="form-label">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">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>Export Format</h5>
|
||||||
|
<div class="form-check">
|
||||||
|
<input class="form-check-input" type="radio" name="exportFormat" id="formatCSV" value="csv" checked>
|
||||||
|
<label class="form-check-label" for="formatCSV">
|
||||||
|
CSV (Comma Separated Values)
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div class="form-check">
|
||||||
|
<input class="form-check-input" type="radio" name="exportFormat" id="formatJSON" value="json">
|
||||||
|
<label class="form-check-label" for="formatJSON">
|
||||||
|
JSON (JavaScript Object Notation)
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6">
|
||||||
|
<button id="exportButton" class="btn btn-primary mt-4">
|
||||||
|
<i class="bi bi-download"></i> Export Data
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="alert alert-info mt-3">
|
||||||
|
<h5>Export Information</h5>
|
||||||
|
<p>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>Exported data will include:</p>
|
||||||
|
<ul>
|
||||||
|
<li>Event ID</li>
|
||||||
|
<li>User</li>
|
||||||
|
<li>Event Type</li>
|
||||||
|
<li>Timestamp</li>
|
||||||
|
<li>Event Data</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Help Tab -->
|
||||||
|
<div class="tab-pane fade" id="help" role="tabpanel" aria-labelledby="help-tab">
|
||||||
|
<div class="card dashboard-card mt-4">
|
||||||
|
<div class="card-header">
|
||||||
|
<h3 class="h5 mb-0">Audit System Help</h3>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<h4>About the Audit System</h4>
|
||||||
|
<p>The Stirling PDF audit system records user actions and system events for security monitoring, compliance, and troubleshooting purposes.</p>
|
||||||
|
|
||||||
|
<h4>Audit Levels</h4>
|
||||||
|
<div class="table-responsive">
|
||||||
|
<table class="table table-bordered">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Level</th>
|
||||||
|
<th>Name</th>
|
||||||
|
<th>Description</th>
|
||||||
|
<th>Use Case</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td>0</td>
|
||||||
|
<td><span class="level-indicator level-0">OFF</span></td>
|
||||||
|
<td>Minimal auditing, only critical security events</td>
|
||||||
|
<td>Development environments</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>1</td>
|
||||||
|
<td><span class="level-indicator level-1">BASIC</span></td>
|
||||||
|
<td>Authentication events, security events, and errors</td>
|
||||||
|
<td>Production environments with minimal storage</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>2</td>
|
||||||
|
<td><span class="level-indicator level-2">STANDARD</span></td>
|
||||||
|
<td>All HTTP requests and operations (default)</td>
|
||||||
|
<td>Normal production use</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>3</td>
|
||||||
|
<td><span class="level-indicator level-3">VERBOSE</span></td>
|
||||||
|
<td>Detailed information including headers, parameters, and results</td>
|
||||||
|
<td>Troubleshooting and detailed analysis</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h4>Configuration</h4>
|
||||||
|
<p>Audit settings are configured in the <code>settings.yml</code> file under the <code>premium.proFeatures.audit</code> section:</p>
|
||||||
|
<pre><code>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</code></pre>
|
||||||
|
|
||||||
|
<h4>Common Event Types</h4>
|
||||||
|
<ul>
|
||||||
|
<th:block th:each="level : ${auditLevels}">
|
||||||
|
<li th:if="${level.name() == 'BASIC'}">
|
||||||
|
<strong>BASIC Events:</strong>
|
||||||
|
<ul>
|
||||||
|
<li>USER_LOGIN - User login</li>
|
||||||
|
<li>USER_LOGOUT - User logout</li>
|
||||||
|
<li>USER_FAILED_LOGIN - Failed login attempt</li>
|
||||||
|
<li>USER_PROFILE_UPDATE - User or profile operations</li>
|
||||||
|
</ul>
|
||||||
|
</li>
|
||||||
|
<li th:if="${level.name() == 'STANDARD'}">
|
||||||
|
<strong>STANDARD Events:</strong>
|
||||||
|
<ul>
|
||||||
|
<li>HTTP_REQUEST - GET requests for viewing</li>
|
||||||
|
<li>PDF_PROCESS - PDF processing operations</li>
|
||||||
|
<li>FILE_UPLOAD - File uploads</li>
|
||||||
|
<li>FILE_DOWNLOAD - File downloads</li>
|
||||||
|
<li>SETTINGS_CHANGED - System or admin settings operations</li>
|
||||||
|
</ul>
|
||||||
|
</li>
|
||||||
|
<li th:if="${level.name() == 'VERBOSE'}">
|
||||||
|
<strong>VERBOSE Events:</strong>
|
||||||
|
<ul>
|
||||||
|
<li>Detailed versions of STANDARD events with parameters and results</li>
|
||||||
|
</ul>
|
||||||
|
</li>
|
||||||
|
</th:block>
|
||||||
|
</ul>
|
||||||
|
</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>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
// 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
|
||||||
|
const auditTableBody = document.getElementById('auditTableBody');
|
||||||
|
const pagination = document.getElementById('pagination');
|
||||||
|
const pageSizeSelect = document.getElementById('pageSizeSelect');
|
||||||
|
const typeFilterInput = document.getElementById('typeFilter');
|
||||||
|
const principalFilterInput = document.getElementById('principalFilter');
|
||||||
|
const startDateFilterInput = document.getElementById('startDateFilter');
|
||||||
|
const endDateFilterInput = document.getElementById('endDateFilter');
|
||||||
|
const applyFiltersButton = document.getElementById('applyFilters');
|
||||||
|
const resetFiltersButton = document.getElementById('resetFilters');
|
||||||
|
|
||||||
|
// Modal elements
|
||||||
|
const eventDetailsModal = document.getElementById('eventDetailsModal');
|
||||||
|
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');
|
||||||
|
|
||||||
|
// Export elements
|
||||||
|
const exportTypeFilter = document.getElementById('exportTypeFilter');
|
||||||
|
const exportPrincipalFilter = document.getElementById('exportPrincipalFilter');
|
||||||
|
const exportStartDateFilter = document.getElementById('exportStartDateFilter');
|
||||||
|
const exportEndDateFilter = document.getElementById('exportEndDateFilter');
|
||||||
|
const exportButton = document.getElementById('exportButton');
|
||||||
|
|
||||||
|
// Initialize page
|
||||||
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
|
// Load initial data
|
||||||
|
loadAuditData();
|
||||||
|
loadStats(7);
|
||||||
|
|
||||||
|
// Set up event listeners
|
||||||
|
pageSizeSelect.addEventListener('change', function() {
|
||||||
|
pageSize = parseInt(this.value);
|
||||||
|
currentPage = 0;
|
||||||
|
loadAuditData();
|
||||||
|
});
|
||||||
|
|
||||||
|
applyFiltersButton.addEventListener('click', function() {
|
||||||
|
typeFilter = typeFilterInput.value.trim();
|
||||||
|
principalFilter = principalFilterInput.value.trim();
|
||||||
|
startDateFilter = startDateFilterInput.value;
|
||||||
|
endDateFilter = endDateFilterInput.value;
|
||||||
|
currentPage = 0;
|
||||||
|
loadAuditData();
|
||||||
|
});
|
||||||
|
|
||||||
|
resetFiltersButton.addEventListener('click', function() {
|
||||||
|
typeFilterInput.value = '';
|
||||||
|
principalFilterInput.value = '';
|
||||||
|
startDateFilterInput.value = '';
|
||||||
|
endDateFilterInput.value = '';
|
||||||
|
typeFilter = '';
|
||||||
|
principalFilter = '';
|
||||||
|
startDateFilter = '';
|
||||||
|
endDateFilter = '';
|
||||||
|
currentPage = 0;
|
||||||
|
loadAuditData();
|
||||||
|
});
|
||||||
|
|
||||||
|
exportButton.addEventListener('click', function() {
|
||||||
|
const exportFormat = document.querySelector('input[name="exportFormat"]:checked').value;
|
||||||
|
exportAuditData(exportFormat);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Set up tab change events
|
||||||
|
const tabEl = document.querySelector('button[data-bs-toggle="tab"]');
|
||||||
|
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() {
|
||||||
|
showLoading('table-loading');
|
||||||
|
|
||||||
|
let url = `/audit/data?page=${currentPage}&size=${pageSize}`;
|
||||||
|
|
||||||
|
if (typeFilter) url += `&type=${encodeURIComponent(typeFilter)}`;
|
||||||
|
if (principalFilter) url += `&principal=${encodeURIComponent(principalFilter)}`;
|
||||||
|
if (startDateFilter) url += `&startDate=${startDateFilter}`;
|
||||||
|
if (endDateFilter) url += `&endDate=${endDateFilter}`;
|
||||||
|
|
||||||
|
fetch(url)
|
||||||
|
.then(response => response.json())
|
||||||
|
.then(data => {
|
||||||
|
renderTable(data.content);
|
||||||
|
renderPagination(data.totalPages, data.currentPage);
|
||||||
|
totalPages = data.totalPages;
|
||||||
|
hideLoading('table-loading');
|
||||||
|
})
|
||||||
|
.catch(error => {
|
||||||
|
console.error('Error loading audit data:', error);
|
||||||
|
auditTableBody.innerHTML = `<tr><td colspan="5" class="text-center">Error loading data: ${error.message}</td></tr>`;
|
||||||
|
hideLoading('table-loading');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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;
|
||||||
|
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">No audit events found</td></tr>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
auditTableBody.innerHTML = '';
|
||||||
|
|
||||||
|
events.forEach(event => {
|
||||||
|
const row = document.createElement('tr');
|
||||||
|
row.innerHTML = `
|
||||||
|
<td>${event.id}</td>
|
||||||
|
<td>${formatDate(event.timestamp)}</td>
|
||||||
|
<td>${escapeHtml(event.principal)}</td>
|
||||||
|
<td>${escapeHtml(event.type)}</td>
|
||||||
|
<td><button class="btn btn-sm btn-outline-primary view-details">View Details</button></td>
|
||||||
|
`;
|
||||||
|
|
||||||
|
// Store event data for modal
|
||||||
|
row.dataset.event = JSON.stringify(event);
|
||||||
|
|
||||||
|
// Add click handler for details button
|
||||||
|
row.querySelector('.view-details').addEventListener('click', function() {
|
||||||
|
showEventDetails(event);
|
||||||
|
});
|
||||||
|
|
||||||
|
auditTableBody.appendChild(row);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show event details in modal
|
||||||
|
function showEventDetails(event) {
|
||||||
|
modalId.textContent = event.id;
|
||||||
|
modalPrincipal.textContent = event.principal;
|
||||||
|
modalType.textContent = event.type;
|
||||||
|
modalTimestamp.textContent = formatDate(event.timestamp);
|
||||||
|
|
||||||
|
// Format JSON data
|
||||||
|
try {
|
||||||
|
const dataObj = JSON.parse(event.data);
|
||||||
|
modalData.textContent = JSON.stringify(dataObj, null, 2);
|
||||||
|
} catch (e) {
|
||||||
|
modalData.textContent = event.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show the modal
|
||||||
|
const modal = new bootstrap.Modal(eventDetailsModal);
|
||||||
|
modal.show();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Render pagination controls
|
||||||
|
function renderPagination(totalPages, currentPage) {
|
||||||
|
pagination.innerHTML = '';
|
||||||
|
|
||||||
|
// Previous button
|
||||||
|
const prevLi = document.createElement('li');
|
||||||
|
prevLi.classList.add('page-item');
|
||||||
|
if (currentPage === 0) prevLi.classList.add('disabled');
|
||||||
|
|
||||||
|
const prevLink = document.createElement('a');
|
||||||
|
prevLink.classList.add('page-link');
|
||||||
|
prevLink.href = '#';
|
||||||
|
prevLink.textContent = 'Previous';
|
||||||
|
prevLink.addEventListener('click', function(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
if (currentPage > 0) {
|
||||||
|
goToPage(currentPage - 1);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
prevLi.appendChild(prevLink);
|
||||||
|
pagination.appendChild(prevLi);
|
||||||
|
|
||||||
|
// Page numbers
|
||||||
|
const maxPages = 5; // Max number of page links to show
|
||||||
|
const startPage = Math.max(0, currentPage - Math.floor(maxPages / 2));
|
||||||
|
const endPage = Math.min(totalPages - 1, startPage + maxPages - 1);
|
||||||
|
|
||||||
|
for (let i = startPage; i <= endPage; i++) {
|
||||||
|
const pageLi = document.createElement('li');
|
||||||
|
pageLi.classList.add('page-item');
|
||||||
|
if (i === currentPage) pageLi.classList.add('active');
|
||||||
|
|
||||||
|
const pageLink = document.createElement('a');
|
||||||
|
pageLink.classList.add('page-link');
|
||||||
|
pageLink.href = '#';
|
||||||
|
pageLink.textContent = i + 1;
|
||||||
|
pageLink.addEventListener('click', function(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
goToPage(i);
|
||||||
|
});
|
||||||
|
|
||||||
|
pageLi.appendChild(pageLink);
|
||||||
|
pagination.appendChild(pageLi);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Next button
|
||||||
|
const nextLi = document.createElement('li');
|
||||||
|
nextLi.classList.add('page-item');
|
||||||
|
if (currentPage >= totalPages - 1) nextLi.classList.add('disabled');
|
||||||
|
|
||||||
|
const nextLink = document.createElement('a');
|
||||||
|
nextLink.classList.add('page-link');
|
||||||
|
nextLink.href = '#';
|
||||||
|
nextLink.textContent = 'Next';
|
||||||
|
nextLink.addEventListener('click', function(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
if (currentPage < totalPages - 1) {
|
||||||
|
goToPage(currentPage + 1);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
nextLi.appendChild(nextLink);
|
||||||
|
pagination.appendChild(nextLi);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Navigate to specific page
|
||||||
|
function goToPage(page) {
|
||||||
|
currentPage = page;
|
||||||
|
loadAuditData();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Render charts
|
||||||
|
function renderCharts(data) {
|
||||||
|
// 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);
|
||||||
|
|
||||||
|
// Type chart
|
||||||
|
if (typeChart) {
|
||||||
|
typeChart.destroy();
|
||||||
|
}
|
||||||
|
|
||||||
|
const typeCtx = document.getElementById('typeChart').getContext('2d');
|
||||||
|
typeChart = new Chart(typeCtx, {
|
||||||
|
type: 'bar',
|
||||||
|
data: {
|
||||||
|
labels: typeLabels,
|
||||||
|
datasets: [{
|
||||||
|
label: 'Events by Type',
|
||||||
|
data: typeValues,
|
||||||
|
backgroundColor: 'rgba(54, 162, 235, 0.6)',
|
||||||
|
borderColor: 'rgba(54, 162, 235, 1)',
|
||||||
|
borderWidth: 1
|
||||||
|
}]
|
||||||
|
},
|
||||||
|
options: {
|
||||||
|
responsive: true,
|
||||||
|
maintainAspectRatio: false,
|
||||||
|
scales: {
|
||||||
|
y: {
|
||||||
|
beginAtZero: true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// User chart
|
||||||
|
if (userChart) {
|
||||||
|
userChart.destroy();
|
||||||
|
}
|
||||||
|
|
||||||
|
const userCtx = document.getElementById('userChart').getContext('2d');
|
||||||
|
userChart = new Chart(userCtx, {
|
||||||
|
type: 'pie',
|
||||||
|
data: {
|
||||||
|
labels: userLabels,
|
||||||
|
datasets: [{
|
||||||
|
label: 'Events by User',
|
||||||
|
data: userValues,
|
||||||
|
backgroundColor: [
|
||||||
|
'rgba(54, 162, 235, 0.6)',
|
||||||
|
'rgba(255, 99, 132, 0.6)',
|
||||||
|
'rgba(255, 206, 86, 0.6)',
|
||||||
|
'rgba(75, 192, 192, 0.6)',
|
||||||
|
'rgba(153, 102, 255, 0.6)',
|
||||||
|
'rgba(255, 159, 64, 0.6)'
|
||||||
|
],
|
||||||
|
borderWidth: 1
|
||||||
|
}]
|
||||||
|
},
|
||||||
|
options: {
|
||||||
|
responsive: true,
|
||||||
|
maintainAspectRatio: false
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Time chart
|
||||||
|
if (timeChart) {
|
||||||
|
timeChart.destroy();
|
||||||
|
}
|
||||||
|
|
||||||
|
const timeCtx = document.getElementById('timeChart').getContext('2d');
|
||||||
|
timeChart = new Chart(timeCtx, {
|
||||||
|
type: 'line',
|
||||||
|
data: {
|
||||||
|
labels: timeLabels,
|
||||||
|
datasets: [{
|
||||||
|
label: 'Events Over Time',
|
||||||
|
data: timeValues,
|
||||||
|
backgroundColor: 'rgba(75, 192, 192, 0.2)',
|
||||||
|
borderColor: 'rgba(75, 192, 192, 1)',
|
||||||
|
tension: 0.1,
|
||||||
|
fill: true
|
||||||
|
}]
|
||||||
|
},
|
||||||
|
options: {
|
||||||
|
responsive: true,
|
||||||
|
maintainAspectRatio: false,
|
||||||
|
scales: {
|
||||||
|
y: {
|
||||||
|
beginAtZero: true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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';
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<th:block th:insert="~{fragments/footer.html :: footer}"></th:block>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
@ -66,6 +66,10 @@ premium:
|
|||||||
proFeatures:
|
proFeatures:
|
||||||
database: true # Enable database features
|
database: true # Enable database features
|
||||||
SSOAutoLogin: false
|
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:
|
CustomMetadata:
|
||||||
autoUpdateMetadata: false
|
autoUpdateMetadata: false
|
||||||
author: username
|
author: username
|
||||||
|
@ -119,6 +119,11 @@
|
|||||||
<span class="material-symbols-rounded">analytics</span>
|
<span class="material-symbols-rounded">analytics</span>
|
||||||
<span th:text="#{adminUserSettings.usage}">Usage Statistics</span>
|
<span th:text="#{adminUserSettings.usage}">Usage Statistics</span>
|
||||||
</a>
|
</a>
|
||||||
|
|
||||||
|
<a href="/audit" th:if="${@runningProOrHigher}" class="data-btn data-btn-secondary" title="Audit Dashboard">
|
||||||
|
<span class="material-symbols-rounded">security</span>
|
||||||
|
<span>Audit Dashboard</span>
|
||||||
|
</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Users Table -->
|
<!-- Users Table -->
|
||||||
|
Loading…
x
Reference in New Issue
Block a user