Auditing support (#3739)

# Description of Changes

This pull request introduces a comprehensive auditing system to the
application, along with minor updates to existing utilities and
dependencies. The most significant changes include the addition of
audit-related classes and enums, updates to the `ApplicationProperties`
model to support auditing configuration, and enhancements to utility
methods for handling static and trackable resources.

### Audit System Implementation:

* **Audit Aspect for Method Annotations**: Added `AuditAspect` to
process the new `@Audited` annotation, enabling detailed logging of
method execution, HTTP requests, and operation results based on
configurable audit levels.
(`proprietary/src/main/java/stirling/software/proprietary/audit/AuditAspect.java`)
* **Audit Event Types**: Introduced `AuditEventType` enum to define
standardized event types for auditing, such as authentication events,
file operations, and HTTP requests.
(`proprietary/src/main/java/stirling/software/proprietary/audit/AuditEventType.java`)
* **Audit Levels**: Added `AuditLevel` enum to define different levels
of audit logging (OFF, BASIC, STANDARD, VERBOSE), providing granular
control over the amount of data logged.
(`proprietary/src/main/java/stirling/software/proprietary/audit/AuditLevel.java`)

### Application Properties Update:

* **Audit Configuration in `ProFeatures`**: Updated the `ProFeatures`
class in `ApplicationProperties` to include support for auditing with
configurable retention days, levels, and enablement flags.
(`common/src/main/java/stirling/software/common/model/ApplicationProperties.java`)

### Utility Enhancements:

* **Static and Trackable Resource Handling**: Extended `RequestUriUtils`
methods (`isStaticResource` and `isTrackableResource`) to recognize
`.txt` files as valid static and trackable resources.
(`common/src/main/java/stirling/software/common/util/RequestUriUtils.java`)
[[1]](diffhunk://#diff-de3599037908683f2cd8f170939547612c6fc2203e9207eb4d7966508f92bbcbR22)
[[2]](diffhunk://#diff-de3599037908683f2cd8f170939547612c6fc2203e9207eb4d7966508f92bbcbR39)

### Dependency Update:

* **Spring Validation Starter**: Added `spring-boot-starter-validation`
to project dependencies to support validation mechanisms required for
auditing features. (`proprietary/build.gradle`)


Dashboard WIP

![image](https://github.com/user-attachments/assets/20d86809-63b0-44d6-82d3-bdce2ac77aa3)


![image](https://github.com/user-attachments/assets/53a5ba69-71ab-4247-9a66-7ef86e462b13)

![image](https://github.com/user-attachments/assets/9a53eaed-ebc7-463c-81da-8b1c140f8a8c)


---

## Checklist

### General

- [ ] I have read the [Contribution
Guidelines](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/CONTRIBUTING.md)
- [ ] I have read the [Stirling-PDF Developer
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/DeveloperGuide.md)
(if applicable)
- [ ] I have read the [How to add new languages to
Stirling-PDF](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/HowToAddNewLanguage.md)
(if applicable)
- [ ] I have performed a self-review of my own code
- [ ] My changes generate no new warnings

### Documentation

- [ ] I have updated relevant docs on [Stirling-PDF's doc
repo](https://github.com/Stirling-Tools/Stirling-Tools.github.io/blob/main/docs/)
(if functionality has heavily changed)
- [ ] I have read the section [Add New Translation
Tags](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/HowToAddNewLanguage.md#add-new-translation-tags)
(for new translation tags only)

### UI Changes (if applicable)

- [ ] Screenshots or videos demonstrating the UI changes are attached
(e.g., as comments or direct attachments in the PR)

### Testing (if applicable)

- [ ] I have tested my changes locally. Refer to the [Testing
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/DeveloperGuide.md#6-testing)
for more details.

---------

Co-authored-by: a <a>
Co-authored-by: pixeebot[bot] <104101892+pixeebot[bot]@users.noreply.github.com>
This commit is contained in:
Anthony Stirling
2025-06-18 13:11:36 +01:00
committed by GitHub
parent ee41dc11c2
commit 552f2ced4d
38 changed files with 4168 additions and 17 deletions

View File

@@ -0,0 +1,131 @@
package stirling.software.proprietary.audit;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.StringUtils;
import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.annotation.Around;
import org.aspectj.lang.annotation.Aspect;
import org.aspectj.lang.reflect.MethodSignature;
import org.springframework.stereotype.Component;
import org.springframework.web.context.request.RequestContextHolder;
import org.springframework.web.context.request.ServletRequestAttributes;
import stirling.software.proprietary.config.AuditConfigurationProperties;
import stirling.software.proprietary.service.AuditService;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import java.lang.reflect.Method;
import java.util.HashMap;
import java.util.Map;
/**
* Aspect for processing {@link Audited} annotations.
*/
@Aspect
@Component
@Slf4j
@RequiredArgsConstructor
public class AuditAspect {
private final AuditService auditService;
private final AuditConfigurationProperties auditConfig;
@Around("@annotation(stirling.software.proprietary.audit.Audited)")
public Object auditMethod(ProceedingJoinPoint joinPoint) throws Throwable {
MethodSignature signature = (MethodSignature) joinPoint.getSignature();
Method method = signature.getMethod();
Audited auditedAnnotation = method.getAnnotation(Audited.class);
// Fast path: use unified check to determine if we should audit
// This avoids all data collection if auditing is disabled
if (!AuditUtils.shouldAudit(method, auditConfig)) {
return joinPoint.proceed();
}
// Only create the map once we know we'll use it
Map<String, Object> auditData = AuditUtils.createBaseAuditData(joinPoint, auditedAnnotation.level());
// Add HTTP information if we're in a web context
ServletRequestAttributes attrs = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
if (attrs != null) {
HttpServletRequest req = attrs.getRequest();
String path = req.getRequestURI();
String httpMethod = req.getMethod();
AuditUtils.addHttpData(auditData, httpMethod, path, auditedAnnotation.level());
AuditUtils.addFileData(auditData, joinPoint, auditedAnnotation.level());
}
// Add arguments if requested and if at VERBOSE level, or if specifically requested
boolean includeArgs = auditedAnnotation.includeArgs() &&
(auditedAnnotation.level() == AuditLevel.VERBOSE ||
auditConfig.getAuditLevel() == AuditLevel.VERBOSE);
if (includeArgs) {
AuditUtils.addMethodArguments(auditData, joinPoint, AuditLevel.VERBOSE);
}
// Record start time for latency calculation
long startTime = System.currentTimeMillis();
Object result;
try {
// Execute the method
result = joinPoint.proceed();
// Add success status
auditData.put("status", "success");
// Add result if requested and if at VERBOSE level
boolean includeResult = auditedAnnotation.includeResult() &&
(auditedAnnotation.level() == AuditLevel.VERBOSE ||
auditConfig.getAuditLevel() == AuditLevel.VERBOSE);
if (includeResult && result != null) {
// Use safe string conversion with size limiting
auditData.put("result", AuditUtils.safeToString(result, 1000));
}
return result;
} catch (Throwable ex) {
// Always add failure information regardless of level
auditData.put("status", "failure");
auditData.put("errorType", ex.getClass().getName());
auditData.put("errorMessage", ex.getMessage());
// Re-throw the exception
throw ex;
} finally {
// Add timing information - use isHttpRequest=false to ensure we get timing for non-HTTP methods
HttpServletResponse resp = attrs != null ? attrs.getResponse() : null;
boolean isHttpRequest = attrs != null;
AuditUtils.addTimingData(auditData, startTime, resp, auditedAnnotation.level(), isHttpRequest);
// Resolve the event type based on annotation and context
String httpMethod = null;
String path = null;
if (attrs != null) {
HttpServletRequest req = attrs.getRequest();
httpMethod = req.getMethod();
path = req.getRequestURI();
}
AuditEventType eventType = AuditUtils.resolveEventType(
method,
joinPoint.getTarget().getClass(),
path,
httpMethod,
auditedAnnotation
);
// Check if we should use string type instead
String typeString = auditedAnnotation.typeString();
if (eventType == AuditEventType.HTTP_REQUEST && StringUtils.isNotEmpty(typeString)) {
// Use the string type (for backward compatibility)
auditService.audit(typeString, auditData, auditedAnnotation.level());
} else {
// Use the enum type (preferred)
auditService.audit(eventType, auditData, auditedAnnotation.level());
}
}
}
}

View File

@@ -0,0 +1,62 @@
package stirling.software.proprietary.audit;
/**
* Standardized audit event types for the application.
*/
public enum AuditEventType {
// Authentication events - BASIC level
USER_LOGIN("User login"),
USER_LOGOUT("User logout"),
USER_FAILED_LOGIN("Failed login attempt"),
// User/admin events - BASIC level
USER_PROFILE_UPDATE("User or profile operation"),
// System configuration events - STANDARD level
SETTINGS_CHANGED("System or admin settings operation"),
// File operations - STANDARD level
FILE_OPERATION("File operation"),
// PDF operations - STANDARD level
PDF_PROCESS("PDF processing operation"),
// HTTP requests - STANDARD level
HTTP_REQUEST("HTTP request");
private final String description;
AuditEventType(String description) {
this.description = description;
}
public String getDescription() {
return description;
}
/**
* Get the enum value from a string representation.
* Useful for backward compatibility with string-based event types.
*
* @param type The string representation of the event type
* @return The corresponding enum value or null if not found
*/
public static AuditEventType fromString(String type) {
if (type == null) {
return null;
}
try {
return AuditEventType.valueOf(type);
} catch (IllegalArgumentException e) {
// If the exact enum name doesn't match, try finding a similar one
for (AuditEventType eventType : values()) {
if (eventType.name().equalsIgnoreCase(type) ||
eventType.getDescription().equalsIgnoreCase(type)) {
return eventType;
}
}
return null;
}
}
}

View File

@@ -0,0 +1,80 @@
package stirling.software.proprietary.audit;
/**
* Defines the different levels of audit logging available in the application.
*/
public enum AuditLevel {
/**
* OFF - No audit logging (level 0)
* Disables all audit logging except for critical security events
*/
OFF(0),
/**
* BASIC - Minimal audit logging (level 1)
* Includes:
* - Authentication events (login, logout, failed logins)
* - Password changes
* - User/role changes
* - System configuration changes
*/
BASIC(1),
/**
* STANDARD - Standard audit logging (level 2)
* Includes everything in BASIC plus:
* - All HTTP requests (basic info: URL, method, status)
* - File operations (upload, download, process)
* - PDF operations (view, edit, etc.)
* - User operations
*/
STANDARD(2),
/**
* VERBOSE - Detailed audit logging (level 3)
* Includes everything in STANDARD plus:
* - Request headers and parameters
* - Method parameters
* - Operation results
* - Detailed timing information
*/
VERBOSE(3);
private final int level;
AuditLevel(int level) {
this.level = level;
}
public int getLevel() {
return level;
}
/**
* Checks if this audit level includes the specified level
* @param otherLevel The level to check against
* @return true if this level is equal to or greater than the specified level
*/
public boolean includes(AuditLevel otherLevel) {
return this.level >= otherLevel.level;
}
/**
* Get an AuditLevel from an integer value
* @param level The integer level (0-3)
* @return The corresponding AuditLevel
*/
public static AuditLevel fromInt(int level) {
// Ensure level is within valid bounds
int boundedLevel = Math.min(Math.max(level, 0), 3);
for (AuditLevel auditLevel : values()) {
if (auditLevel.level == boundedLevel) {
return auditLevel;
}
}
// Default to STANDARD if somehow we didn't match
return STANDARD;
}
}

View File

@@ -0,0 +1,375 @@
package stirling.software.proprietary.audit;
import lombok.extern.slf4j.Slf4j;
import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.reflect.MethodSignature;
import org.slf4j.MDC;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.web.context.request.RequestContextHolder;
import org.springframework.web.context.request.ServletRequestAttributes;
import org.springframework.web.multipart.MultipartFile;
import stirling.software.common.util.RequestUriUtils;
import stirling.software.proprietary.config.AuditConfigurationProperties;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import java.lang.reflect.Method;
import java.time.Instant;
import java.util.Arrays;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
import java.util.stream.IntStream;
import org.apache.commons.lang3.StringUtils;
/**
* Shared utilities for audit aspects to ensure consistent behavior
* across different audit mechanisms.
*/
@Slf4j
public class AuditUtils {
/**
* Create a standard audit data map with common attributes based on the current audit level
*
* @param joinPoint The AspectJ join point
* @param auditLevel The current audit level
* @return A map with standard audit data
*/
public static Map<String, Object> createBaseAuditData(ProceedingJoinPoint joinPoint, AuditLevel auditLevel) {
Map<String, Object> data = new HashMap<>();
// Common data for all levels
data.put("timestamp", Instant.now().toString());
// Add principal if available
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
if (auth != null && auth.getName() != null) {
data.put("principal", auth.getName());
} else {
data.put("principal", "system");
}
// Add class name and method name only at VERBOSE level
if (auditLevel.includes(AuditLevel.VERBOSE)) {
data.put("className", joinPoint.getTarget().getClass().getName());
data.put("methodName", ((MethodSignature) joinPoint.getSignature()).getMethod().getName());
}
return data;
}
/**
* Add HTTP-specific information to the audit data if available
*
* @param data The existing audit data map
* @param httpMethod The HTTP method (GET, POST, etc.)
* @param path The request path
* @param auditLevel The current audit level
*/
public static void addHttpData(Map<String, Object> data, String httpMethod, String path, AuditLevel auditLevel) {
if (httpMethod == null || path == null) {
return; // Skip if we don't have basic HTTP info
}
// BASIC level HTTP data
data.put("httpMethod", httpMethod);
data.put("path", path);
// Get request attributes safely
ServletRequestAttributes attrs = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
if (attrs == null) {
return; // No request context available
}
HttpServletRequest req = attrs.getRequest();
if (req == null) {
return; // No request available
}
// STANDARD level HTTP data
if (auditLevel.includes(AuditLevel.STANDARD)) {
data.put("clientIp", req.getRemoteAddr());
data.put("sessionId", req.getSession(false) != null ? req.getSession(false).getId() : null);
data.put("requestId", MDC.get("requestId"));
// Form data for POST/PUT/PATCH
if (("POST".equalsIgnoreCase(httpMethod) ||
"PUT".equalsIgnoreCase(httpMethod) ||
"PATCH".equalsIgnoreCase(httpMethod)) && req.getContentType() != null) {
String contentType = req.getContentType();
if (contentType.contains("application/x-www-form-urlencoded") ||
contentType.contains("multipart/form-data")) {
Map<String, String[]> params = new HashMap<>(req.getParameterMap());
// Remove CSRF token from logged parameters
params.remove("_csrf");
if (!params.isEmpty()) {
data.put("formParams", params);
}
}
}
}
}
/**
* Add file information to the audit data if available
*
* @param data The existing audit data map
* @param joinPoint The AspectJ join point
* @param auditLevel The current audit level
*/
public static void addFileData(Map<String, Object> data, ProceedingJoinPoint joinPoint, AuditLevel auditLevel) {
if (auditLevel.includes(AuditLevel.STANDARD)) {
List<MultipartFile> files = Arrays.stream(joinPoint.getArgs())
.filter(a -> a instanceof MultipartFile)
.map(a -> (MultipartFile)a)
.collect(Collectors.toList());
if (!files.isEmpty()) {
List<Map<String,Object>> fileInfos = files.stream().map(f -> {
Map<String,Object> m = new HashMap<>();
m.put("name", f.getOriginalFilename());
m.put("size", f.getSize());
m.put("type", f.getContentType());
return m;
}).collect(Collectors.toList());
data.put("files", fileInfos);
}
}
}
/**
* Add method arguments to the audit data
*
* @param data The existing audit data map
* @param joinPoint The AspectJ join point
* @param auditLevel The current audit level
*/
public static void addMethodArguments(Map<String, Object> data, ProceedingJoinPoint joinPoint, AuditLevel auditLevel) {
if (auditLevel.includes(AuditLevel.VERBOSE)) {
MethodSignature sig = (MethodSignature) joinPoint.getSignature();
String[] names = sig.getParameterNames();
Object[] vals = joinPoint.getArgs();
if (names != null && vals != null) {
IntStream.range(0, names.length)
.forEach(i -> {
if (vals[i] != null) {
// Convert objects to safe string representation
data.put("arg_" + names[i], safeToString(vals[i], 500));
} else {
data.put("arg_" + names[i], null);
}
});
}
}
}
/**
* Safely convert an object to string with size limiting
*
* @param obj The object to convert
* @param maxLength Maximum length of the resulting string
* @return A safe string representation, truncated if needed
*/
public static String safeToString(Object obj, int maxLength) {
if (obj == null) {
return "null";
}
String result;
try {
// Handle common types directly to avoid toString() overhead
if (obj instanceof String) {
result = (String) obj;
} else if (obj instanceof Number || obj instanceof Boolean) {
result = obj.toString();
} else if (obj instanceof byte[]) {
result = "[binary data length=" + ((byte[]) obj).length + "]";
} else {
// For complex objects, use toString but handle exceptions
result = obj.toString();
}
// Truncate if necessary
if (result != null && result.length() > maxLength) {
return StringUtils.truncate(result, maxLength - 3) + "...";
}
return result;
} catch (Exception e) {
// If toString() fails, return the class name
return "[" + obj.getClass().getName() + " - toString() failed]";
}
}
/**
* Determine if a method should be audited based on config and annotation
*
* @param method The method to check
* @param auditConfig The audit configuration
* @return true if the method should be audited
*/
public static boolean shouldAudit(Method method, AuditConfigurationProperties auditConfig) {
// First check if audit is globally enabled - fast path
if (!auditConfig.isEnabled()) {
return false;
}
// Check for annotation override
Audited auditedAnnotation = method.getAnnotation(Audited.class);
AuditLevel requiredLevel = (auditedAnnotation != null)
? auditedAnnotation.level()
: AuditLevel.BASIC;
// Check if the required level is enabled
return auditConfig.getAuditLevel().includes(requiredLevel);
}
/**
* Add timing and response status data to the audit record
*
* @param data The audit data to add to
* @param startTime The start time in milliseconds
* @param response The HTTP response (may be null for non-HTTP methods)
* @param level The current audit level
* @param isHttpRequest Whether this is an HTTP request (controller) or a regular method call
*/
public static void addTimingData(Map<String, Object> data, long startTime, HttpServletResponse response, AuditLevel level, boolean isHttpRequest) {
if (level.includes(AuditLevel.STANDARD)) {
// For HTTP requests, let ControllerAuditAspect handle timing separately
// For non-HTTP methods, add execution time here
if (!isHttpRequest) {
data.put("latencyMs", System.currentTimeMillis() - startTime);
}
// Add HTTP status code if available
if (response != null) {
try {
data.put("statusCode", response.getStatus());
} catch (Exception e) {
// Ignore - response might be in an inconsistent state
}
}
}
}
/**
* Resolve the event type to use for auditing, considering annotations and context
*
* @param method The method being audited
* @param controller The controller class
* @param path The request path (may be null for non-HTTP methods)
* @param httpMethod The HTTP method (may be null for non-HTTP methods)
* @param annotation The @Audited annotation (may be null)
* @return The resolved event type (never null)
*/
public static AuditEventType resolveEventType(Method method, Class<?> controller, String path, String httpMethod, Audited annotation) {
// First check if we have an explicit annotation
if (annotation != null && annotation.type() != AuditEventType.HTTP_REQUEST) {
return annotation.type();
}
// For HTTP methods, infer based on controller and path
if (httpMethod != null && path != null) {
String cls = controller.getSimpleName().toLowerCase();
String pkg = controller.getPackage().getName().toLowerCase();
if ("GET".equals(httpMethod)) return AuditEventType.HTTP_REQUEST;
if (cls.contains("user") || cls.contains("auth") || pkg.contains("auth")
|| path.startsWith("/user") || path.startsWith("/login")) {
return AuditEventType.USER_PROFILE_UPDATE;
} else if (cls.contains("admin") || path.startsWith("/admin") || path.startsWith("/settings")) {
return AuditEventType.SETTINGS_CHANGED;
} else if (cls.contains("file") || path.startsWith("/file")
|| path.matches("(?i).*/(upload|download)/.*")) {
return AuditEventType.FILE_OPERATION;
}
}
// Default for non-HTTP methods or when no specific match
return AuditEventType.PDF_PROCESS;
}
/**
* Determine the appropriate audit level to use
*
* @param method The method to check
* @param defaultLevel The default level to use if no annotation present
* @param auditConfig The audit configuration
* @return The audit level to use
*/
public static AuditLevel getEffectiveAuditLevel(Method method, AuditLevel defaultLevel, AuditConfigurationProperties auditConfig) {
Audited auditedAnnotation = method.getAnnotation(Audited.class);
if (auditedAnnotation != null) {
// Method has @Audited - use its level
return auditedAnnotation.level();
}
// Use default level (typically from global config)
return defaultLevel;
}
/**
* Determine the appropriate audit event type to use
*
* @param method The method being audited
* @param controller The controller class
* @param path The request path
* @param httpMethod The HTTP method
* @return The determined audit event type
*/
public static AuditEventType determineAuditEventType(Method method, Class<?> controller, String path, String httpMethod) {
// First check for explicit annotation
Audited auditedAnnotation = method.getAnnotation(Audited.class);
if (auditedAnnotation != null) {
return auditedAnnotation.type();
}
// Otherwise infer from controller and path
String cls = controller.getSimpleName().toLowerCase();
String pkg = controller.getPackage().getName().toLowerCase();
if ("GET".equals(httpMethod)) return AuditEventType.HTTP_REQUEST;
if (cls.contains("user") || cls.contains("auth") || pkg.contains("auth")
|| path.startsWith("/user") || path.startsWith("/login")) {
return AuditEventType.USER_PROFILE_UPDATE;
} else if (cls.contains("admin") || path.startsWith("/admin") || path.startsWith("/settings")) {
return AuditEventType.SETTINGS_CHANGED;
} else if (cls.contains("file") || path.startsWith("/file")
|| path.matches("(?i).*/(upload|download)/.*")) {
return AuditEventType.FILE_OPERATION;
} else {
return AuditEventType.PDF_PROCESS;
}
}
/**
* Get the current HTTP request if available
*
* @return The current request or null if not in a request context
*/
public static HttpServletRequest getCurrentRequest() {
ServletRequestAttributes attrs = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
return attrs != null ? attrs.getRequest() : null;
}
/**
* Check if a GET request is for a static resource
*
* @param request The HTTP request
* @return true if this is a static resource request
*/
public static boolean isStaticResourceRequest(HttpServletRequest request) {
return request != null && !RequestUriUtils.isTrackableResource(
request.getContextPath(), request.getRequestURI());
}
}

View File

@@ -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;
}

View File

@@ -0,0 +1,211 @@
package stirling.software.proprietary.audit;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.StringUtils;
import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.annotation.Around;
import org.aspectj.lang.annotation.Aspect;
import org.aspectj.lang.reflect.MethodSignature;
import org.springframework.stereotype.Component;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PatchMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.context.request.RequestContextHolder;
import org.springframework.web.context.request.ServletRequestAttributes;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import stirling.software.proprietary.config.AuditConfigurationProperties;
import stirling.software.proprietary.service.AuditService;
import java.lang.annotation.Annotation;
import java.lang.reflect.Method;
import java.util.HashMap;
import java.util.Map;
/**
* Aspect for automatically auditing controller methods with web mappings
* (GetMapping, PostMapping, etc.)
*/
@Aspect
@Component
@Slf4j
@RequiredArgsConstructor
public class ControllerAuditAspect {
private final AuditService auditService;
private final AuditConfigurationProperties auditConfig;
@Around("execution(* org.springframework.web.servlet.resource.ResourceHttpRequestHandler.handleRequest(..))")
public Object auditStaticResource(ProceedingJoinPoint jp) throws Throwable {
return auditController(jp, "GET");
}
/**
* Intercept all methods with GetMapping annotation
*/
@Around("@annotation(org.springframework.web.bind.annotation.GetMapping)")
public Object auditGetMethod(ProceedingJoinPoint joinPoint) throws Throwable {
return auditController(joinPoint, "GET");
}
/**
* Intercept all methods with PostMapping annotation
*/
@Around("@annotation(org.springframework.web.bind.annotation.PostMapping)")
public Object auditPostMethod(ProceedingJoinPoint joinPoint) throws Throwable {
return auditController(joinPoint, "POST");
}
/**
* Intercept all methods with PutMapping annotation
*/
@Around("@annotation(org.springframework.web.bind.annotation.PutMapping)")
public Object auditPutMethod(ProceedingJoinPoint joinPoint) throws Throwable {
return auditController(joinPoint, "PUT");
}
/**
* Intercept all methods with DeleteMapping annotation
*/
@Around("@annotation(org.springframework.web.bind.annotation.DeleteMapping)")
public Object auditDeleteMethod(ProceedingJoinPoint joinPoint) throws Throwable {
return auditController(joinPoint, "DELETE");
}
/**
* Intercept all methods with PatchMapping annotation
*/
@Around("@annotation(org.springframework.web.bind.annotation.PatchMapping)")
public Object auditPatchMethod(ProceedingJoinPoint joinPoint) throws Throwable {
return auditController(joinPoint, "PATCH");
}
private Object auditController(ProceedingJoinPoint joinPoint, String httpMethod) throws Throwable {
MethodSignature sig = (MethodSignature) joinPoint.getSignature();
Method method = sig.getMethod();
// Fast path: check if auditing is enabled before doing any work
// This avoids all data collection if auditing is disabled
if (!AuditUtils.shouldAudit(method, auditConfig)) {
return joinPoint.proceed();
}
// Check if method is explicitly annotated with @Audited
Audited auditedAnnotation = method.getAnnotation(Audited.class);
AuditLevel level = auditConfig.getAuditLevel();
// If @Audited annotation is present, respect its level setting
if (auditedAnnotation != null) {
// Use the level from annotation if it's stricter than global level
level = auditedAnnotation.level();
}
String path = getRequestPath(method, httpMethod);
// Skip static GET resources
if ("GET".equals(httpMethod)) {
HttpServletRequest maybe = AuditUtils.getCurrentRequest();
if (maybe != null && AuditUtils.isStaticResourceRequest(maybe)) {
return joinPoint.proceed();
}
}
ServletRequestAttributes attrs = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
HttpServletRequest req = attrs != null ? attrs.getRequest() : null;
HttpServletResponse resp = attrs != null ? attrs.getResponse() : null;
long start = System.currentTimeMillis();
// Use AuditUtils to create the base audit data
Map<String, Object> data = AuditUtils.createBaseAuditData(joinPoint, level);
// Add HTTP-specific information
AuditUtils.addHttpData(data, httpMethod, path, level);
// Add file information if present
AuditUtils.addFileData(data, joinPoint, level);
// Add method arguments if at VERBOSE level
if (level.includes(AuditLevel.VERBOSE)) {
AuditUtils.addMethodArguments(data, joinPoint, level);
}
Object result = null;
try {
result = joinPoint.proceed();
data.put("outcome", "success");
} catch (Throwable ex) {
data.put("outcome", "failure");
data.put("errorType", ex.getClass().getSimpleName());
data.put("errorMessage", ex.getMessage());
throw ex;
} finally {
// Handle timing directly for HTTP requests
if (level.includes(AuditLevel.STANDARD)) {
data.put("latencyMs", System.currentTimeMillis() - start);
if (resp != null) data.put("statusCode", resp.getStatus());
}
// Call AuditUtils but with isHttpRequest=true to skip additional timing
AuditUtils.addTimingData(data, start, resp, level, true);
// Add result for VERBOSE level
if (level.includes(AuditLevel.VERBOSE) && result != null) {
// Use safe string conversion with size limiting
data.put("result", AuditUtils.safeToString(result, 1000));
}
// Resolve the event type using the unified method
AuditEventType eventType = AuditUtils.resolveEventType(
method,
joinPoint.getTarget().getClass(),
path,
httpMethod,
auditedAnnotation
);
// Check if we should use string type instead (for backward compatibility)
if (auditedAnnotation != null) {
String typeString = auditedAnnotation.typeString();
if (eventType == AuditEventType.HTTP_REQUEST && StringUtils.isNotEmpty(typeString)) {
auditService.audit(typeString, data, level);
return result;
}
}
// Use the enum type
auditService.audit(eventType, data, level);
}
return result;
}
// Using AuditUtils.determineAuditEventType instead
private String getRequestPath(Method method, String httpMethod) {
String base = "";
RequestMapping cm = method.getDeclaringClass().getAnnotation(RequestMapping.class);
if (cm != null && cm.value().length > 0) base = cm.value()[0];
String mp = "";
Annotation ann = switch (httpMethod) {
case "GET" -> method.getAnnotation(GetMapping.class);
case "POST" -> method.getAnnotation(PostMapping.class);
case "PUT" -> method.getAnnotation(PutMapping.class);
case "DELETE" -> method.getAnnotation(DeleteMapping.class);
case "PATCH" -> method.getAnnotation(PatchMapping.class);
default -> null;
};
if (ann instanceof GetMapping gm && gm.value().length > 0) mp = gm.value()[0];
if (ann instanceof PostMapping pm && pm.value().length > 0) mp = pm.value()[0];
if (ann instanceof PutMapping pum && pum.value().length > 0) mp = pum.value()[0];
if (ann instanceof DeleteMapping dm && dm.value().length > 0) mp = dm.value()[0];
if (ann instanceof PatchMapping pam && pam.value().length > 0) mp = pam.value()[0];
return base + mp;
}
// Using AuditUtils.getCurrentRequest instead
}

View File

@@ -0,0 +1,57 @@
package stirling.software.proprietary.config;
import org.slf4j.MDC;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.core.task.TaskDecorator;
import org.springframework.scheduling.annotation.EnableAsync;
import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor;
import java.util.Map;
import java.util.concurrent.Executor;
@Configuration
@EnableAsync
public class AsyncConfig {
/**
* MDC context-propagating task decorator
* Copies MDC context from the caller thread to the async executor thread
*/
static class MDCContextTaskDecorator implements TaskDecorator {
@Override
public Runnable decorate(Runnable runnable) {
// Capture the MDC context from the current thread
Map<String, String> contextMap = MDC.getCopyOfContextMap();
return () -> {
try {
// Set the captured context on the worker thread
if (contextMap != null) {
MDC.setContextMap(contextMap);
}
// Execute the task
runnable.run();
} finally {
// Clear the context to prevent memory leaks
MDC.clear();
}
};
}
}
@Bean(name = "auditExecutor")
public Executor auditExecutor() {
ThreadPoolTaskExecutor exec = new ThreadPoolTaskExecutor();
exec.setCorePoolSize(2);
exec.setMaxPoolSize(8);
exec.setQueueCapacity(1_000);
exec.setThreadNamePrefix("audit-");
// Set the task decorator to propagate MDC context
exec.setTaskDecorator(new MDCContextTaskDecorator());
exec.initialize();
return exec;
}
}

View File

@@ -0,0 +1,68 @@
package stirling.software.proprietary.config;
import lombok.Getter;
import lombok.extern.slf4j.Slf4j;
import org.springframework.core.Ordered;
import org.springframework.core.annotation.Order;
import org.springframework.stereotype.Component;
import stirling.software.common.model.ApplicationProperties;
import stirling.software.proprietary.audit.AuditLevel;
/**
* Configuration properties for the audit system.
* Reads values from the ApplicationProperties under premium.enterpriseFeatures.audit
*/
@Slf4j
@Getter
@Component
@Order(Ordered.HIGHEST_PRECEDENCE + 10)
public class AuditConfigurationProperties {
private final boolean enabled;
private final int level;
private final int retentionDays;
public AuditConfigurationProperties(ApplicationProperties applicationProperties) {
ApplicationProperties.Premium.EnterpriseFeatures.Audit auditConfig =
applicationProperties.getPremium().getEnterpriseFeatures().getAudit();
// Read values directly from configuration
this.enabled = auditConfig.isEnabled();
// Ensure level is within valid bounds (0-3)
int configLevel = auditConfig.getLevel();
this.level = Math.min(Math.max(configLevel, 0), 3);
// Retention days (0 means infinite)
this.retentionDays = auditConfig.getRetentionDays();
log.debug("Initialized audit configuration: enabled={}, level={}, retentionDays={} (0=infinite)",
this.enabled, this.level, this.retentionDays);
}
/**
* Get the audit level as an enum
* @return The current AuditLevel
*/
public AuditLevel getAuditLevel() {
return AuditLevel.fromInt(level);
}
/**
* Check if the current audit level includes the specified level
* @param requiredLevel The level to check against
* @return true if auditing is enabled and the current level includes the required level
*/
public boolean isLevelEnabled(AuditLevel requiredLevel) {
return enabled && getAuditLevel().includes(requiredLevel);
}
/**
* Get the effective retention period in days
* @return The number of days to retain audit records, or -1 for infinite retention
*/
public int getEffectiveRetentionDays() {
// 0 means infinite retention
return retentionDays <= 0 ? -1 : retentionDays;
}
}

View File

@@ -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
}

View File

@@ -0,0 +1,74 @@
package stirling.software.proprietary.config;
import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.slf4j.MDC;
import org.springframework.boot.actuate.audit.AuditEvent;
import org.springframework.boot.actuate.audit.AuditEventRepository;
import org.springframework.context.annotation.Primary;
import org.springframework.scheduling.annotation.Async;
import org.springframework.stereotype.Component;
import org.springframework.util.CollectionUtils;
import stirling.software.proprietary.model.security.PersistentAuditEvent;
import stirling.software.proprietary.repository.PersistentAuditEventRepository;
import stirling.software.proprietary.util.SecretMasker;
import java.time.Instant;
import java.util.List;
import java.util.Map;
@Component
@Primary
@RequiredArgsConstructor
@Slf4j
public class CustomAuditEventRepository implements AuditEventRepository {
private final PersistentAuditEventRepository repo;
private final ObjectMapper mapper;
/* ── READ side intentionally inert (endpoint disabled) ── */
@Override
public List<AuditEvent> find(String p, Instant after, String type) {
return List.of();
}
/* ── WRITE side (async) ───────────────────────────────── */
@Async("auditExecutor")
@Override
public void add(AuditEvent ev) {
try {
Map<String, Object> clean =
CollectionUtils.isEmpty(ev.getData())
? Map.of()
: SecretMasker.mask(ev.getData());
if (clean.isEmpty() ||
(clean.size() == 1 && clean.containsKey("details"))) {
return;
}
String rid = MDC.get("requestId");
if (rid != null) {
clean = new java.util.HashMap<>(clean);
clean.put("requestId", rid);
}
String auditEventData = mapper.writeValueAsString(clean);
log.debug("AuditEvent data (JSON): {}",auditEventData);
PersistentAuditEvent ent = PersistentAuditEvent.builder()
.principal(ev.getPrincipal())
.type(ev.getType())
.data(auditEventData)
.timestamp(ev.getTimestamp())
.build();
repo.save(ent);
} catch (Exception e) {
e.printStackTrace(); // fail-open
}
}
}

View File

@@ -0,0 +1,344 @@
package stirling.software.proprietary.controller;
import java.time.Instant;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.time.ZoneId;
import java.time.format.DateTimeFormatter;
import java.util.Arrays;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.stream.Collectors;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Pageable;
import org.springframework.data.domain.Sort;
import org.springframework.format.annotation.DateTimeFormat;
import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.ResponseBody;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import jakarta.servlet.http.HttpServletRequest;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import stirling.software.proprietary.audit.AuditEventType;
import stirling.software.proprietary.audit.AuditLevel;
import stirling.software.proprietary.config.AuditConfigurationProperties;
import stirling.software.proprietary.model.security.PersistentAuditEvent;
import stirling.software.proprietary.repository.PersistentAuditEventRepository;
import stirling.software.proprietary.security.config.EnterpriseEndpoint;
/**
* Controller for the audit dashboard.
* Admin-only access.
*/
@Slf4j
@Controller
@RequestMapping("/audit")
@PreAuthorize("hasRole('ADMIN')")
@RequiredArgsConstructor
@EnterpriseEndpoint
public class AuditDashboardController {
private final PersistentAuditEventRepository auditRepository;
private final AuditConfigurationProperties auditConfig;
private final ObjectMapper objectMapper;
/**
* Display the audit dashboard.
*/
@GetMapping
public String showDashboard(Model model) {
model.addAttribute("auditEnabled", auditConfig.isEnabled());
model.addAttribute("auditLevel", auditConfig.getAuditLevel());
model.addAttribute("auditLevelInt", auditConfig.getLevel());
model.addAttribute("retentionDays", auditConfig.getRetentionDays());
// Add audit level enum values for display
model.addAttribute("auditLevels", AuditLevel.values());
// Add audit event types for the dropdown
model.addAttribute("auditEventTypes", AuditEventType.values());
return "audit/dashboard";
}
/**
* Get audit events data for the dashboard tables.
*/
@GetMapping("/data")
@ResponseBody
public Map<String, Object> getAuditData(
@RequestParam(value = "page", defaultValue = "0") int page,
@RequestParam(value = "size", defaultValue = "30") int size,
@RequestParam(value = "type", required = false) String type,
@RequestParam(value = "principal", required = false) String principal,
@RequestParam(value = "startDate", required = false)
@DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate startDate,
@RequestParam(value = "endDate", required = false)
@DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate endDate, HttpServletRequest request) {
Pageable pageable = PageRequest.of(page, size, Sort.by("timestamp").descending());
Page<PersistentAuditEvent> events;
String mode;
if (type != null && principal != null && startDate != null && endDate != null) {
mode = "principal + type + startDate + endDate";
Instant start = startDate.atStartOfDay(ZoneId.systemDefault()).toInstant();
Instant end = endDate.plusDays(1).atStartOfDay(ZoneId.systemDefault()).toInstant();
events = auditRepository.findByPrincipalAndTypeAndTimestampBetween(principal, type, start, end, pageable);
} else if (type != null && principal != null) {
mode = "principal + type";
events = auditRepository.findByPrincipalAndType(principal, type, pageable);
} else if (type != null && startDate != null && endDate != null) {
mode = "type + startDate + endDate";
Instant start = startDate.atStartOfDay(ZoneId.systemDefault()).toInstant();
Instant end = endDate.plusDays(1).atStartOfDay(ZoneId.systemDefault()).toInstant();
events = auditRepository.findByTypeAndTimestampBetween(type, start, end, pageable);
} else if (principal != null && startDate != null && endDate != null) {
mode = "principal + startDate + endDate";
Instant start = startDate.atStartOfDay(ZoneId.systemDefault()).toInstant();
Instant end = endDate.plusDays(1).atStartOfDay(ZoneId.systemDefault()).toInstant();
events = auditRepository.findByPrincipalAndTimestampBetween(principal, start, end, pageable);
} else if (startDate != null && endDate != null) {
mode = "startDate + endDate";
Instant start = startDate.atStartOfDay(ZoneId.systemDefault()).toInstant();
Instant end = endDate.plusDays(1).atStartOfDay(ZoneId.systemDefault()).toInstant();
events = auditRepository.findByTimestampBetween(start, end, pageable);
} else if (type != null) {
mode = "type";
events = auditRepository.findByType(type, pageable);
} else if (principal != null) {
mode = "principal";
events = auditRepository.findByPrincipal(principal, pageable);
} else {
mode = "all";
events = auditRepository.findAll(pageable);
}
// Logging
List<PersistentAuditEvent> content = events.getContent();
Map<String, Object> response = new HashMap<>();
response.put("content", content);
response.put("totalPages", events.getTotalPages());
response.put("totalElements", events.getTotalElements());
response.put("currentPage", events.getNumber());
return response;
}
/**
* Get statistics for charts.
*/
@GetMapping("/stats")
@ResponseBody
public Map<String, Object> getAuditStats(
@RequestParam(value = "days", defaultValue = "7") int days) {
// Get events from the last X days
Instant startDate = Instant.now().minus(java.time.Duration.ofDays(days));
List<PersistentAuditEvent> events = auditRepository.findByTimestampAfter(startDate);
// Count events by type
Map<String, Long> eventsByType = events.stream()
.collect(Collectors.groupingBy(PersistentAuditEvent::getType, Collectors.counting()));
// Count events by principal
Map<String, Long> eventsByPrincipal = events.stream()
.collect(Collectors.groupingBy(PersistentAuditEvent::getPrincipal, Collectors.counting()));
// Count events by day
Map<String, Long> eventsByDay = events.stream()
.collect(Collectors.groupingBy(
e -> LocalDateTime.ofInstant(e.getTimestamp(), ZoneId.systemDefault())
.format(DateTimeFormatter.ISO_LOCAL_DATE),
Collectors.counting()));
Map<String, Object> stats = new HashMap<>();
stats.put("eventsByType", eventsByType);
stats.put("eventsByPrincipal", eventsByPrincipal);
stats.put("eventsByDay", eventsByDay);
stats.put("totalEvents", events.size());
return stats;
}
/**
* Get all unique event types from the database for filtering.
*/
@GetMapping("/types")
@ResponseBody
public List<String> getAuditTypes() {
// Get distinct event types from the database
List<String> dbTypes = auditRepository.findDistinctEventTypes();
// Include standard enum types in case they're not in the database yet
List<String> enumTypes = Arrays.stream(AuditEventType.values())
.map(AuditEventType::name)
.collect(Collectors.toList());
// Combine both sources, remove duplicates, and sort
Set<String> combinedTypes = new HashSet<>();
combinedTypes.addAll(dbTypes);
combinedTypes.addAll(enumTypes);
return combinedTypes.stream().sorted().collect(Collectors.toList());
}
/**
* Export audit data as CSV.
*/
@GetMapping("/export")
public ResponseEntity<byte[]> exportAuditData(
@RequestParam(value = "type", required = false) String type,
@RequestParam(value = "principal", required = false) String principal,
@RequestParam(value = "startDate", required = false)
@DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate startDate,
@RequestParam(value = "endDate", required = false)
@DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate endDate) {
// Get data with same filtering as getAuditData
List<PersistentAuditEvent> events;
if (type != null && principal != null && startDate != null && endDate != null) {
Instant start = startDate.atStartOfDay(ZoneId.systemDefault()).toInstant();
Instant end = endDate.plusDays(1).atStartOfDay(ZoneId.systemDefault()).toInstant();
events = auditRepository.findAllByPrincipalAndTypeAndTimestampBetweenForExport(
principal, type, start, end);
} else if (type != null && principal != null) {
events = auditRepository.findAllByPrincipalAndTypeForExport(principal, type);
} else if (type != null && startDate != null && endDate != null) {
Instant start = startDate.atStartOfDay(ZoneId.systemDefault()).toInstant();
Instant end = endDate.plusDays(1).atStartOfDay(ZoneId.systemDefault()).toInstant();
events = auditRepository.findAllByTypeAndTimestampBetweenForExport(type, start, end);
} else if (principal != null && startDate != null && endDate != null) {
Instant start = startDate.atStartOfDay(ZoneId.systemDefault()).toInstant();
Instant end = endDate.plusDays(1).atStartOfDay(ZoneId.systemDefault()).toInstant();
events = auditRepository.findAllByPrincipalAndTimestampBetweenForExport(principal, start, end);
} else if (startDate != null && endDate != null) {
Instant start = startDate.atStartOfDay(ZoneId.systemDefault()).toInstant();
Instant end = endDate.plusDays(1).atStartOfDay(ZoneId.systemDefault()).toInstant();
events = auditRepository.findAllByTimestampBetweenForExport(start, end);
} else if (type != null) {
events = auditRepository.findByTypeForExport(type);
} else if (principal != null) {
events = auditRepository.findAllByPrincipalForExport(principal);
} else {
events = auditRepository.findAll();
}
// Convert to CSV
StringBuilder csv = new StringBuilder();
csv.append("ID,Principal,Type,Timestamp,Data\n");
DateTimeFormatter formatter = DateTimeFormatter.ISO_INSTANT;
for (PersistentAuditEvent event : events) {
csv.append(event.getId()).append(",");
csv.append(escapeCSV(event.getPrincipal())).append(",");
csv.append(escapeCSV(event.getType())).append(",");
csv.append(formatter.format(event.getTimestamp())).append(",");
csv.append(escapeCSV(event.getData())).append("\n");
}
byte[] csvBytes = csv.toString().getBytes();
// Set up HTTP headers for download
HttpHeaders headers = new HttpHeaders();
headers.setContentType(MediaType.APPLICATION_OCTET_STREAM);
headers.setContentDispositionFormData("attachment", "audit_export.csv");
return ResponseEntity.ok()
.headers(headers)
.body(csvBytes);
}
/**
* Export audit data as JSON.
*/
@GetMapping("/export/json")
public ResponseEntity<byte[]> exportAuditDataJson(
@RequestParam(value = "type", required = false) String type,
@RequestParam(value = "principal", required = false) String principal,
@RequestParam(value = "startDate", required = false)
@DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate startDate,
@RequestParam(value = "endDate", required = false)
@DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate endDate) {
// Get data with same filtering as getAuditData
List<PersistentAuditEvent> events;
if (type != null && principal != null && startDate != null && endDate != null) {
Instant start = startDate.atStartOfDay(ZoneId.systemDefault()).toInstant();
Instant end = endDate.plusDays(1).atStartOfDay(ZoneId.systemDefault()).toInstant();
events = auditRepository.findAllByPrincipalAndTypeAndTimestampBetweenForExport(
principal, type, start, end);
} else if (type != null && principal != null) {
events = auditRepository.findAllByPrincipalAndTypeForExport(principal, type);
} else if (type != null && startDate != null && endDate != null) {
Instant start = startDate.atStartOfDay(ZoneId.systemDefault()).toInstant();
Instant end = endDate.plusDays(1).atStartOfDay(ZoneId.systemDefault()).toInstant();
events = auditRepository.findAllByTypeAndTimestampBetweenForExport(type, start, end);
} else if (principal != null && startDate != null && endDate != null) {
Instant start = startDate.atStartOfDay(ZoneId.systemDefault()).toInstant();
Instant end = endDate.plusDays(1).atStartOfDay(ZoneId.systemDefault()).toInstant();
events = auditRepository.findAllByPrincipalAndTimestampBetweenForExport(principal, start, end);
} else if (startDate != null && endDate != null) {
Instant start = startDate.atStartOfDay(ZoneId.systemDefault()).toInstant();
Instant end = endDate.plusDays(1).atStartOfDay(ZoneId.systemDefault()).toInstant();
events = auditRepository.findAllByTimestampBetweenForExport(start, end);
} else if (type != null) {
events = auditRepository.findByTypeForExport(type);
} else if (principal != null) {
events = auditRepository.findAllByPrincipalForExport(principal);
} else {
events = auditRepository.findAll();
}
// Convert to JSON
try {
byte[] jsonBytes = objectMapper.writeValueAsBytes(events);
// Set up HTTP headers for download
HttpHeaders headers = new HttpHeaders();
headers.setContentType(MediaType.APPLICATION_JSON);
headers.setContentDispositionFormData("attachment", "audit_export.json");
return ResponseEntity.ok()
.headers(headers)
.body(jsonBytes);
} catch (JsonProcessingException e) {
log.error("Error serializing audit events to JSON", e);
return ResponseEntity.internalServerError().build();
}
}
/**
* Helper method to escape CSV fields.
*/
private String escapeCSV(String field) {
if (field == null) {
return "";
}
// Replace double quotes with two double quotes and wrap in quotes
return "\"" + field.replace("\"", "\"\"") + "\"";
}
}

View File

@@ -0,0 +1,34 @@
package stirling.software.proprietary.model.security;
import jakarta.persistence.*;
import lombok.*;
import org.hibernate.annotations.Index;
import java.time.Instant;
@Entity
@Table(
name = "audit_events",
indexes = {
@jakarta.persistence.Index(name = "idx_audit_timestamp", columnList = "timestamp"),
@jakarta.persistence.Index(name = "idx_audit_principal", columnList = "principal"),
@jakarta.persistence.Index(name = "idx_audit_type", columnList = "type"),
@jakarta.persistence.Index(name = "idx_audit_principal_type", columnList = "principal,type"),
@jakarta.persistence.Index(name = "idx_audit_type_timestamp", columnList = "type,timestamp")
}
)
@Data @Builder @NoArgsConstructor @AllArgsConstructor
public class PersistentAuditEvent {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String principal;
private String type;
@Lob
private String data; // JSON blob
private Instant timestamp;
}

View File

@@ -0,0 +1,72 @@
package stirling.software.proprietary.repository;
import java.time.Instant;
import java.util.List;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Modifying;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import org.springframework.stereotype.Repository;
import org.springframework.transaction.annotation.Transactional;
import stirling.software.proprietary.model.security.PersistentAuditEvent;
@Repository
public interface PersistentAuditEventRepository
extends JpaRepository<PersistentAuditEvent, Long> {
// Basic queries
@Query("SELECT e FROM PersistentAuditEvent e WHERE UPPER(e.principal) LIKE UPPER(CONCAT('%', :principal, '%'))")
Page<PersistentAuditEvent> findByPrincipal(@Param("principal") String principal, Pageable pageable);
Page<PersistentAuditEvent> findByType(String type, Pageable pageable);
Page<PersistentAuditEvent> findByTimestampBetween(Instant startDate, Instant endDate, Pageable pageable);
@Query("SELECT e FROM PersistentAuditEvent e WHERE UPPER(e.principal) LIKE UPPER(CONCAT('%', :principal, '%')) AND e.type = :type")
Page<PersistentAuditEvent> findByPrincipalAndType(@Param("principal") String principal, @Param("type") String type, Pageable pageable);
@Query("SELECT e FROM PersistentAuditEvent e WHERE UPPER(e.principal) LIKE UPPER(CONCAT('%', :principal, '%')) AND e.timestamp BETWEEN :startDate AND :endDate")
Page<PersistentAuditEvent> findByPrincipalAndTimestampBetween(@Param("principal") String principal, @Param("startDate") Instant startDate, @Param("endDate") Instant endDate, Pageable pageable);
Page<PersistentAuditEvent> findByTypeAndTimestampBetween(String type, Instant startDate, Instant endDate, Pageable pageable);
@Query("SELECT e FROM PersistentAuditEvent e WHERE UPPER(e.principal) LIKE UPPER(CONCAT('%', :principal, '%')) AND e.type = :type AND e.timestamp BETWEEN :startDate AND :endDate")
Page<PersistentAuditEvent> findByPrincipalAndTypeAndTimestampBetween(@Param("principal") String principal, @Param("type") String type, @Param("startDate") Instant startDate, @Param("endDate") Instant endDate, Pageable pageable);
// Non-paged versions for export
@Query("SELECT e FROM PersistentAuditEvent e WHERE UPPER(e.principal) LIKE UPPER(CONCAT('%', :principal, '%'))")
List<PersistentAuditEvent> findAllByPrincipalForExport(@Param("principal") String principal);
@Query("SELECT e FROM PersistentAuditEvent e WHERE e.type = :type")
List<PersistentAuditEvent> findByTypeForExport(@Param("type") String type);
@Query("SELECT e FROM PersistentAuditEvent e WHERE e.timestamp BETWEEN :startDate AND :endDate")
List<PersistentAuditEvent> findAllByTimestampBetweenForExport(@Param("startDate") Instant startDate, @Param("endDate") Instant endDate);
@Query("SELECT e FROM PersistentAuditEvent e WHERE e.timestamp > :startDate")
List<PersistentAuditEvent> findByTimestampAfter(@Param("startDate") Instant startDate);
@Query("SELECT e FROM PersistentAuditEvent e WHERE UPPER(e.principal) LIKE UPPER(CONCAT('%', :principal, '%')) AND e.type = :type")
List<PersistentAuditEvent> findAllByPrincipalAndTypeForExport(@Param("principal") String principal, @Param("type") String type);
@Query("SELECT e FROM PersistentAuditEvent e WHERE UPPER(e.principal) LIKE UPPER(CONCAT('%', :principal, '%')) AND e.timestamp BETWEEN :startDate AND :endDate")
List<PersistentAuditEvent> findAllByPrincipalAndTimestampBetweenForExport(@Param("principal") String principal, @Param("startDate") Instant startDate, @Param("endDate") Instant endDate);
@Query("SELECT e FROM PersistentAuditEvent e WHERE e.type = :type AND e.timestamp BETWEEN :startDate AND :endDate")
List<PersistentAuditEvent> findAllByTypeAndTimestampBetweenForExport(@Param("type") String type, @Param("startDate") Instant startDate, @Param("endDate") Instant endDate);
@Query("SELECT e FROM PersistentAuditEvent e WHERE UPPER(e.principal) LIKE UPPER(CONCAT('%', :principal, '%')) AND e.type = :type AND e.timestamp BETWEEN :startDate AND :endDate")
List<PersistentAuditEvent> findAllByPrincipalAndTypeAndTimestampBetweenForExport(@Param("principal") String principal, @Param("type") String type, @Param("startDate") Instant startDate, @Param("endDate") Instant endDate);
// Cleanup queries
@Query("DELETE FROM PersistentAuditEvent e WHERE e.timestamp < ?1")
@Modifying
@Transactional
int deleteByTimestampBefore(Instant cutoffDate);
// Find IDs for batch deletion - using JPQL with setMaxResults instead of native query
@Query("SELECT e.id FROM PersistentAuditEvent e WHERE e.timestamp < ?1 ORDER BY e.id")
List<Long> findIdsForBatchDeletion(Instant cutoffDate, Pageable pageable);
// Stats queries
@Query("SELECT e.type, COUNT(e) FROM PersistentAuditEvent e GROUP BY e.type")
List<Object[]> countByType();
@Query("SELECT e.principal, COUNT(e) FROM PersistentAuditEvent e GROUP BY e.principal")
List<Object[]> countByPrincipal();
// Get distinct event types for filtering
@Query("SELECT DISTINCT e.type FROM PersistentAuditEvent e ORDER BY e.type")
List<String> findDistinctEventTypes();
}

View File

@@ -4,6 +4,8 @@ import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.util.HashMap;
import java.util.Map;
import java.util.Optional;
import lombok.extern.slf4j.Slf4j;
import org.springframework.security.authentication.BadCredentialsException;
@@ -13,6 +15,16 @@ import org.springframework.security.authentication.LockedException;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.security.web.authentication.SimpleUrlAuthenticationFailureHandler;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.extern.slf4j.Slf4j;
import stirling.software.proprietary.audit.AuditEventType;
import stirling.software.proprietary.audit.AuditLevel;
import stirling.software.proprietary.audit.Audited;
import stirling.software.proprietary.security.model.User;
import stirling.software.proprietary.security.service.LoginAttemptService;
import stirling.software.proprietary.security.service.UserService;
@@ -31,6 +43,7 @@ public class CustomAuthenticationFailureHandler extends SimpleUrlAuthenticationF
}
@Override
@Audited(type = AuditEventType.USER_FAILED_LOGIN, level = AuditLevel.BASIC)
public void onAuthenticationFailure(
HttpServletRequest request,
HttpServletResponse response,

View File

@@ -1,4 +1,11 @@
package stirling.software.proprietary.security;
import java.io.IOException;
import java.util.HashMap;
import java.util.Map;
import org.springframework.security.core.Authentication;
import org.springframework.security.web.authentication.SavedRequestAwareAuthenticationSuccessHandler;
import org.springframework.security.web.savedrequest.SavedRequest;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
@@ -10,6 +17,9 @@ import org.springframework.security.core.Authentication;
import org.springframework.security.web.authentication.SavedRequestAwareAuthenticationSuccessHandler;
import org.springframework.security.web.savedrequest.SavedRequest;
import stirling.software.common.util.RequestUriUtils;
import stirling.software.proprietary.audit.AuditEventType;
import stirling.software.proprietary.audit.AuditLevel;
import stirling.software.proprietary.audit.Audited;
import stirling.software.proprietary.security.service.LoginAttemptService;
import stirling.software.proprietary.security.service.UserService;
@@ -27,6 +37,7 @@ public class CustomAuthenticationSuccessHandler
}
@Override
@Audited(type = AuditEventType.USER_LOGIN, level = AuditLevel.BASIC)
public void onAuthenticationSuccess(
HttpServletRequest request, HttpServletResponse response, Authentication authentication)
throws ServletException, IOException {

View File

@@ -23,6 +23,9 @@ import stirling.software.common.model.ApplicationProperties.Security.OAUTH2;
import stirling.software.common.model.ApplicationProperties.Security.SAML2;
import stirling.software.common.model.oauth2.KeycloakProvider;
import stirling.software.common.util.UrlUtils;
import stirling.software.proprietary.audit.AuditEventType;
import stirling.software.proprietary.audit.AuditLevel;
import stirling.software.proprietary.audit.Audited;
import stirling.software.proprietary.security.saml2.CertificateUtils;
import stirling.software.proprietary.security.saml2.CustomSaml2AuthenticatedPrincipal;
@@ -37,6 +40,7 @@ public class CustomLogoutSuccessHandler extends SimpleUrlLogoutSuccessHandler {
private final AppConfig appConfig;
@Override
@Audited(type = AuditEventType.USER_LOGOUT, level = AuditLevel.BASIC)
public void onLogoutSuccess(
HttpServletRequest request, HttpServletResponse response, Authentication authentication)
throws IOException {

View File

@@ -62,8 +62,10 @@ public class InitialSecuritySetup {
}
userService.saveAll(usersWithoutTeam); // batch save
if(usersWithoutTeam != null && !usersWithoutTeam.isEmpty()) {
log.info(
"Assigned {} user(s) without a team to the default team.", usersWithoutTeam.size());
}
}
private void initializeAdminUser() throws SQLException, UnsupportedProviderException {

View File

@@ -0,0 +1,11 @@
package stirling.software.proprietary.security.config;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
/** Annotation to mark endpoints that require an Enterprise license. */
@Target({ElementType.METHOD, ElementType.TYPE})
@Retention(RetentionPolicy.RUNTIME)
public @interface EnterpriseEndpoint {}

View File

@@ -0,0 +1,30 @@
package stirling.software.proprietary.security.config;
import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.annotation.Around;
import org.aspectj.lang.annotation.Aspect;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Component;
import org.springframework.web.server.ResponseStatusException;
@Aspect
@Component
public class EnterpriseEndpointAspect {
private final boolean runningEE;
public EnterpriseEndpointAspect(@Qualifier("runningEE") boolean runningEE) {
this.runningEE = runningEE;
}
@Around(
"@annotation(stirling.software.proprietary.security.config.EnterpriseEndpoint) || @within(stirling.software.proprietary.security.config.EnterpriseEndpoint)")
public Object checkEnterpriseAccess(ProceedingJoinPoint joinPoint) throws Throwable {
if (!runningEE) {
throw new ResponseStatusException(
HttpStatus.FORBIDDEN, "This endpoint requires an Enterprise license");
}
return joinPoint.proceed();
}
}

View File

@@ -0,0 +1,112 @@
package stirling.software.proprietary.service;
import java.time.Instant;
import java.time.temporal.ChronoUnit;
import java.util.List;
import java.util.concurrent.TimeUnit;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Sort;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import stirling.software.proprietary.config.AuditConfigurationProperties;
import stirling.software.proprietary.repository.PersistentAuditEventRepository;
/**
* Service to periodically clean up old audit events based on retention policy.
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class AuditCleanupService {
private final PersistentAuditEventRepository auditRepository;
private final AuditConfigurationProperties auditConfig;
// Default batch size for deletions
private static final int BATCH_SIZE = 10000;
/**
* Scheduled task that runs daily to clean up old audit events.
* The retention period is configurable in settings.yml.
*/
@Scheduled(fixedDelay = 1, initialDelay = 1, timeUnit = TimeUnit.DAYS)
public void cleanupOldAuditEvents() {
if (!auditConfig.isEnabled()) {
return;
}
int retentionDays = auditConfig.getRetentionDays();
if (retentionDays <= 0) {
return;
}
log.info("Starting audit cleanup for events older than {} days", retentionDays);
try {
Instant cutoffDate = Instant.now().minus(retentionDays, ChronoUnit.DAYS);
int totalDeleted = batchDeleteEvents(cutoffDate);
log.info("Successfully cleaned up {} audit events older than {}", totalDeleted, cutoffDate);
} catch (Exception e) {
log.error("Error cleaning up old audit events", e);
}
}
/**
* Performs batch deletion of events to prevent long-running transactions
* and potential database locks.
*/
private int batchDeleteEvents(Instant cutoffDate) {
int totalDeleted = 0;
boolean hasMore = true;
while (hasMore) {
// Start a new transaction for each batch
List<Long> batchIds = findBatchOfIdsToDelete(cutoffDate);
if (batchIds.isEmpty()) {
hasMore = false;
} else {
int deleted = deleteBatch(batchIds);
totalDeleted += deleted;
// If we got fewer records than the batch size, we're done
if (batchIds.size() < BATCH_SIZE) {
hasMore = false;
}
}
}
return totalDeleted;
}
/**
* Finds a batch of IDs to delete.
*/
@Transactional(readOnly = true)
private List<Long> findBatchOfIdsToDelete(Instant cutoffDate) {
PageRequest pageRequest = PageRequest.of(0, BATCH_SIZE, Sort.by("id"));
return auditRepository.findIdsForBatchDeletion(cutoffDate, pageRequest);
}
/**
* Deletes a batch of events by ID.
* Each batch is in its own transaction.
*/
@Transactional
private int deleteBatch(List<Long> batchIds) {
if (batchIds.isEmpty()) {
return 0;
}
int batchSize = batchIds.size();
auditRepository.deleteAllByIdInBatch(batchIds);
log.debug("Deleted batch of {} audit events", batchSize);
return batchSize;
}
}

View File

@@ -0,0 +1,165 @@
package stirling.software.proprietary.service;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.boot.actuate.audit.AuditEvent;
import org.springframework.boot.actuate.audit.AuditEventRepository;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.stereotype.Service;
import stirling.software.proprietary.audit.AuditEventType;
import stirling.software.proprietary.audit.AuditLevel;
import stirling.software.proprietary.config.AuditConfigurationProperties;
import java.util.Map;
/**
* Service for creating manual audit events throughout the application.
* This provides easy access to audit functionality in any component.
*/
@Slf4j
@Service
public class AuditService {
private final AuditEventRepository repository;
private final AuditConfigurationProperties auditConfig;
private final boolean runningEE;
public AuditService(AuditEventRepository repository,
AuditConfigurationProperties auditConfig,
@org.springframework.beans.factory.annotation.Qualifier("runningEE") boolean runningEE) {
this.repository = repository;
this.auditConfig = auditConfig;
this.runningEE = runningEE;
}
/**
* Record an audit event for the current authenticated user with a specific audit level
* using the standardized AuditEventType enum
*
* @param type The event type from AuditEventType enum
* @param data Additional event data (will be automatically sanitized)
* @param level The minimum audit level required for this event to be logged
*/
public void audit(AuditEventType type, Map<String, Object> data, AuditLevel level) {
// Skip auditing if this level is not enabled or if not Enterprise edition
if (!auditConfig.isEnabled() || !auditConfig.getAuditLevel().includes(level) || !runningEE) {
return;
}
String principal = getCurrentUsername();
repository.add(new AuditEvent(principal, type.name(), data));
}
/**
* Record an audit event for the current authenticated user with standard level
* using the standardized AuditEventType enum
*
* @param type The event type from AuditEventType enum
* @param data Additional event data (will be automatically sanitized)
*/
public void audit(AuditEventType type, Map<String, Object> data) {
// Default to STANDARD level
audit(type, data, AuditLevel.STANDARD);
}
/**
* Record an audit event for a specific user with a specific audit level
* using the standardized AuditEventType enum
*
* @param principal The username or system identifier
* @param type The event type from AuditEventType enum
* @param data Additional event data (will be automatically sanitized)
* @param level The minimum audit level required for this event to be logged
*/
public void audit(String principal, AuditEventType type, Map<String, Object> data, AuditLevel level) {
// Skip auditing if this level is not enabled or if not Enterprise edition
if (!auditConfig.isLevelEnabled(level) || !runningEE) {
return;
}
repository.add(new AuditEvent(principal, type.name(), data));
}
/**
* Record an audit event for a specific user with standard level
* using the standardized AuditEventType enum
*
* @param principal The username or system identifier
* @param type The event type from AuditEventType enum
* @param data Additional event data (will be automatically sanitized)
*/
public void audit(String principal, AuditEventType type, Map<String, Object> data) {
// Default to STANDARD level
audit(principal, type, data, AuditLevel.STANDARD);
}
/**
* Record an audit event for the current authenticated user with a specific audit level
* using a string-based event type (for backward compatibility)
*
* @param type The event type (e.g., "FILE_UPLOAD", "PASSWORD_CHANGE")
* @param data Additional event data (will be automatically sanitized)
* @param level The minimum audit level required for this event to be logged
*/
public void audit(String type, Map<String, Object> data, AuditLevel level) {
// Skip auditing if this level is not enabled or if not Enterprise edition
if (!auditConfig.isLevelEnabled(level) || !runningEE) {
return;
}
String principal = getCurrentUsername();
repository.add(new AuditEvent(principal, type, data));
}
/**
* Record an audit event for the current authenticated user with standard level
* using a string-based event type (for backward compatibility)
*
* @param type The event type (e.g., "FILE_UPLOAD", "PASSWORD_CHANGE")
* @param data Additional event data (will be automatically sanitized)
*/
public void audit(String type, Map<String, Object> data) {
// Default to STANDARD level
audit(type, data, AuditLevel.STANDARD);
}
/**
* Record an audit event for a specific user with a specific audit level
* using a string-based event type (for backward compatibility)
*
* @param principal The username or system identifier
* @param type The event type (e.g., "FILE_UPLOAD", "PASSWORD_CHANGE")
* @param data Additional event data (will be automatically sanitized)
* @param level The minimum audit level required for this event to be logged
*/
public void audit(String principal, String type, Map<String, Object> data, AuditLevel level) {
// Skip auditing if this level is not enabled or if not Enterprise edition
if (!auditConfig.isLevelEnabled(level) || !runningEE) {
return;
}
repository.add(new AuditEvent(principal, type, data));
}
/**
* Record an audit event for a specific user with standard level
* using a string-based event type (for backward compatibility)
*
* @param principal The username or system identifier
* @param type The event type (e.g., "FILE_UPLOAD", "PASSWORD_CHANGE")
* @param data Additional event data (will be automatically sanitized)
*/
public void audit(String principal, String type, Map<String, Object> data) {
// Default to STANDARD level
audit(principal, type, data, AuditLevel.STANDARD);
}
/**
* Get the current authenticated username or "system" if none
*/
private String getCurrentUsername() {
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
return (auth != null && auth.getName() != null) ? auth.getName() : "system";
}
}

View File

@@ -0,0 +1,61 @@
package stirling.software.proprietary.util;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.regex.Pattern;
import java.util.stream.Collectors;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.SerializationFeature;
import lombok.extern.slf4j.Slf4j;
/** Redacts any map values whose keys match common secret/token patterns. */
@Slf4j
public final class SecretMasker {
private static final Pattern SENSITIVE =
Pattern.compile("(?i)(password|token|secret|api[_-]?key|authorization|auth|jwt|cred|cert)");
private SecretMasker() {}
public static Map<String,Object> mask(Map<String,Object> in) {
if (in == null) return null;
return in.entrySet().stream()
.filter(e -> e.getValue() != null)
.collect(Collectors.toMap(
Map.Entry::getKey,
e -> deepMaskValue(e.getKey(), e.getValue())
));
}
private static Object deepMask(Object value) {
if (value instanceof Map<?,?> m) {
return m.entrySet().stream()
.filter(e -> e.getValue() != null)
.collect(Collectors.toMap(
Map.Entry::getKey,
e -> deepMaskValue((String)e.getKey(), e.getValue())
));
} else if (value instanceof List<?> list) {
return list.stream()
.map(SecretMasker::deepMask).toList();
} else {
return value;
}
}
private static Object deepMaskValue(String key, Object value) {
if (key != null && SENSITIVE.matcher(key).find()) {
return "***REDACTED***";
}
return deepMask(value);
}
}

View File

@@ -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");
}
}
}

View File

@@ -0,0 +1,47 @@
package stirling.software.proprietary.web;
import io.github.pixee.security.Newlines;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.extern.slf4j.Slf4j;
import org.slf4j.MDC;
import org.springframework.stereotype.Component;
import org.springframework.util.StringUtils;
import org.springframework.web.filter.OncePerRequestFilter;
import java.io.IOException;
import java.util.UUID;
/**
* Guarantees every request carries a stable X-Request-Id; propagates to MDC.
*/
@Slf4j
@Component
public class CorrelationIdFilter extends OncePerRequestFilter {
public static final String HEADER = "X-Request-Id";
public static final String MDC_KEY = "requestId";
@Override
protected void doFilterInternal(HttpServletRequest req,
HttpServletResponse res,
FilterChain chain)
throws ServletException, IOException {
try {
String id = req.getHeader(HEADER);
if (!StringUtils.hasText(id)) {
id = UUID.randomUUID().toString();
}
req.setAttribute(MDC_KEY, id);
MDC.put(MDC_KEY, id);
res.setHeader(HEADER, Newlines.stripAll(id));
chain.doFilter(req, res);
} finally {
MDC.remove(MDC_KEY);
}
}
}