Compare commits

...
Author SHA1 Message Date
Anthony Stirling c99674f430 audit stuff 2026-01-04 23:47:49 +00:00
9 changed files with 333 additions and 43 deletions
@@ -699,6 +699,7 @@ public class ApplicationProperties {
private boolean enabled = true;
private int level = 2; // 0=OFF, 1=BASIC, 2=STANDARD, 3=VERBOSE
private int retentionDays = 90;
private boolean logIpAddresses = false; // Privacy: IP logging disabled by default
}
@Data
@@ -97,6 +97,7 @@ premium:
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
logIpAddresses: false # Log client IP addresses (privacy-sensitive)
databaseNotifications:
backups:
successful: false # set to 'true' to enable email notifications for successful database backups
@@ -49,13 +49,38 @@ public class AuditAspect {
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();
// Try to find HttpServletRequest from method arguments first (for Security handlers)
HttpServletRequest request = null;
for (Object arg : joinPoint.getArgs()) {
if (arg instanceof HttpServletRequest) {
request = (HttpServletRequest) arg;
break;
}
}
// Fall back to RequestContextHolder if not in method args
if (request == null) {
ServletRequestAttributes attrs =
(ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
if (attrs != null) {
request = attrs.getRequest();
}
}
// Capture principal, origin, and IP early (before any async execution)
// Extract IP directly from the request we already have (more reliable than
// RequestContextHolder)
String capturedPrincipal = auditService.captureCurrentPrincipal();
String capturedOrigin = auditService.captureCurrentOrigin();
String capturedIp = null;
if (request != null && auditConfig.isLogIpAddresses()) {
capturedIp = AuditUtils.extractClientIp(request);
}
// Add HTTP information if we have a valid request
if (request != null) {
String path = request.getRequestURI();
String httpMethod = request.getMethod();
AuditUtils.addHttpData(auditData, httpMethod, path, auditedAnnotation.level());
AuditUtils.addFileData(auditData, joinPoint, auditedAnnotation.level());
}
@@ -101,20 +126,43 @@ public class AuditAspect {
// 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);
// Find HttpServletResponse from method arguments first
HttpServletResponse response = null;
for (Object arg : joinPoint.getArgs()) {
if (arg instanceof HttpServletResponse) {
response = (HttpServletResponse) arg;
break;
}
}
// Fall back to RequestContextHolder for response (most controllers don't have it in
// args)
if (response == null) {
ServletRequestAttributes attrs =
(ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
if (attrs != null) {
response = attrs.getResponse();
}
}
// Add timing directly (like ControllerAuditAspect) when we have a request
if (auditedAnnotation.level().includes(AuditLevel.STANDARD)) {
auditData.put("latencyMs", System.currentTimeMillis() - startTime);
if (response != null) {
try {
auditData.put("statusCode", response.getStatus());
} catch (Exception e) {
// Ignore
}
}
}
// 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();
if (request != null) {
httpMethod = request.getMethod();
path = request.getRequestURI();
}
AuditEventType eventType =
@@ -128,11 +176,24 @@ public class AuditAspect {
// 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());
// Use the string type (for backward compatibility) with captured principal, origin,
// and IP
auditService.audit(
capturedPrincipal,
capturedOrigin,
capturedIp,
typeString,
auditData,
auditedAnnotation.level());
} else {
// Use the enum type (preferred)
auditService.audit(eventType, auditData, auditedAnnotation.level());
// Use the enum type (preferred) with captured principal, origin, and IP
auditService.audit(
capturedPrincipal,
capturedOrigin,
capturedIp,
eventType,
auditData,
auditedAnnotation.level());
}
}
}
@@ -48,17 +48,6 @@ public class AuditUtils {
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());
@@ -102,7 +91,6 @@ public class AuditUtils {
// 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);
@@ -424,4 +412,38 @@ public class AuditUtils {
&& !RequestUriUtils.isTrackableResource(
request.getContextPath(), request.getRequestURI());
}
/**
* Extract client IP address from HTTP request, preferring forwarded headers for use behind
* proxies/load balancers. Truncates to 45 characters to fit database column constraints.
*
* @param request The HTTP request (may be null)
* @return The client IP address, or null if not available
*/
public static String extractClientIp(HttpServletRequest request) {
if (request == null) {
return null;
}
// Prefer X-Forwarded-For header (used by most proxies/load balancers)
String xff = request.getHeader("X-Forwarded-For");
if (xff != null && !xff.isBlank()) {
// X-Forwarded-For can be a comma-separated list: "client, proxy1, proxy2"
// Take the first (client) IP
String first = xff.split(",")[0].trim();
return first.length() > 45 ? first.substring(0, 45) : first;
}
// Try X-Real-IP header (used by some reverse proxies like nginx)
String realIp = request.getHeader("X-Real-IP");
if (realIp != null && !realIp.isBlank()) {
return realIp.length() > 45 ? realIp.substring(0, 45) : realIp;
}
// Fall back to remote address
String remoteAddr = request.getRemoteAddr();
return remoteAddr != null && remoteAddr.length() > 45
? remoteAddr.substring(0, 45)
: remoteAddr;
}
}
@@ -25,6 +25,7 @@ import jakarta.servlet.http.HttpServletResponse;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import stirling.software.common.annotations.AutoJobPostMapping;
import stirling.software.proprietary.config.AuditConfigurationProperties;
import stirling.software.proprietary.service.AuditService;
@@ -37,7 +38,7 @@ import stirling.software.proprietary.service.AuditService;
@Slf4j
@RequiredArgsConstructor
@org.springframework.core.annotation.Order(
10) // Lower precedence (higher number) - executes after AutoJobAspect
0) // Highest precedence - runs on request thread before AutoJobAspect
public class ControllerAuditAspect {
private final AuditService auditService;
@@ -121,6 +122,16 @@ public class ControllerAuditAspect {
HttpServletRequest req = attrs != null ? attrs.getRequest() : null;
HttpServletResponse resp = attrs != null ? attrs.getResponse() : null;
// Capture principal, origin, and IP (runs on request thread before AutoJobAspect)
String capturedPrincipal = auditService.captureCurrentPrincipal();
String capturedOrigin = auditService.captureCurrentOrigin();
// Extract IP directly from the request we already have (more reliable than
// RequestContextHolder)
String capturedIp = null;
if (req != null && auditConfig.isLogIpAddresses()) {
capturedIp = AuditUtils.extractClientIp(req);
}
long start = System.currentTimeMillis();
// Use AuditUtils to create the base audit data
@@ -176,13 +187,15 @@ public class ControllerAuditAspect {
String typeString = auditedAnnotation.typeString();
if (eventType == AuditEventType.HTTP_REQUEST
&& StringUtils.isNotEmpty(typeString)) {
auditService.audit(typeString, data, level);
auditService.audit(
capturedPrincipal, capturedOrigin, capturedIp, typeString, data, level);
return result;
}
}
// Use the enum type
auditService.audit(eventType, data, level);
// Use the enum type with captured principal, origin, and IP
auditService.audit(
capturedPrincipal, capturedOrigin, capturedIp, eventType, data, level);
}
return result;
}
@@ -194,6 +207,14 @@ public class ControllerAuditAspect {
RequestMapping cm = method.getDeclaringClass().getAnnotation(RequestMapping.class);
if (cm != null && cm.value().length > 0) base = cm.value()[0];
String mp = "";
// First check for AutoJobPostMapping (which is also a POST)
AutoJobPostMapping autoJob = method.getAnnotation(AutoJobPostMapping.class);
if (autoJob != null && autoJob.value().length > 0) {
return base + autoJob.value()[0];
}
// Fall back to standard mappings
Annotation ann =
switch (httpMethod) {
case "GET" -> method.getAnnotation(GetMapping.class);
@@ -23,6 +23,7 @@ public class AuditConfigurationProperties {
private final boolean enabled;
private final int level;
private final int retentionDays;
private final boolean logIpAddresses;
public AuditConfigurationProperties(ApplicationProperties applicationProperties) {
ApplicationProperties.Premium.EnterpriseFeatures.Audit auditConfig =
@@ -37,11 +38,15 @@ public class AuditConfigurationProperties {
// Retention days (0 means infinite)
this.retentionDays = auditConfig.getRetentionDays();
// IP address logging (default false for privacy)
this.logIpAddresses = auditConfig.isLogIpAddresses();
log.debug(
"Initialized audit configuration: enabled={}, level={}, retentionDays={} (0=infinite)",
"Initialized audit configuration: enabled={}, level={}, retentionDays={} (0=infinite), logIpAddresses={}",
this.enabled,
this.level,
this.retentionDays);
this.retentionDays,
this.logIpAddresses);
}
/**
@@ -9,6 +9,8 @@ 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.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.stereotype.Component;
import org.springframework.util.CollectionUtils;
@@ -49,6 +51,23 @@ public class CustomAuditEventRepository implements AuditEventRepository {
if (clean.isEmpty() || (clean.size() == 1 && clean.containsKey("details"))) {
return;
}
// Extract origin and IP from the data map (set by AuditService before async call)
String origin = "SYSTEM"; // default
String ipAddress = null;
if (clean.containsKey("__origin") || clean.containsKey("__ipAddress")) {
clean = new java.util.HashMap<>(clean);
if (clean.containsKey("__origin")) {
origin = String.valueOf(clean.remove("__origin"));
}
if (clean.containsKey("__ipAddress")) {
ipAddress = String.valueOf(clean.remove("__ipAddress"));
}
}
String rid = MDC.get("requestId");
if (rid != null) {
@@ -59,16 +78,60 @@ public class CustomAuditEventRepository implements AuditEventRepository {
String auditEventData = mapper.writeValueAsString(clean);
log.debug("AuditEvent data (JSON): {}", auditEventData);
String principalName = extractPrincipalName(ev.getPrincipal());
if (principalName.length() > 255) {
log.warn(
"Principal length {} exceeds 255 characters, truncating: {}",
principalName.length(),
principalName.substring(0, Math.min(50, principalName.length())) + "...");
principalName = principalName.substring(0, 255);
}
if (ev.getType().length() > 255) {
log.warn(
"Type length {} exceeds 255 characters: {}",
ev.getType().length(),
ev.getType());
}
PersistentAuditEvent ent =
PersistentAuditEvent.builder()
.principal(ev.getPrincipal())
.principal(principalName)
.type(ev.getType())
.data(auditEventData)
.timestamp(ev.getTimestamp())
.ipAddress(ipAddress)
.origin(origin)
.build();
repo.save(ent);
} catch (Exception e) {
e.printStackTrace(); // fail-open
}
}
/**
* Extracts a meaningful principal name from the audit event principal. Uses lightweight
* approach to avoid performance impact on high-volume audit events.
*/
private String extractPrincipalName(String principal) {
if (principal == null || principal.isEmpty()) {
return "anonymous";
}
// Quick check for JWT tokens (they start with "eyJ")
if (principal.startsWith("eyJ")) {
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
if (auth != null && auth.isAuthenticated()) {
String name = auth.getName();
if (name != null && !name.startsWith("eyJ")) {
return name.length() > 255 ? name.substring(0, 255) : name;
}
}
// Unverified/failed JWT; don't trust token content
return "authentication-failure";
}
return principal.length() > 255 ? principal.substring(0, 255) : principal;
}
}
@@ -13,6 +13,7 @@ import lombok.*;
@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_origin", columnList = "origin"),
@jakarta.persistence.Index(
name = "idx_audit_principal_type",
columnList = "principal,type"),
@@ -33,7 +34,14 @@ public class PersistentAuditEvent {
private String principal;
private String type;
@Lob private String data; // JSON blob
@Column(columnDefinition = "TEXT")
private String data; // JSON blob
private Instant timestamp;
@Column(length = 45) // Max length for IPv6 (xxx:xxx:xxx:xxx:xxx:xxx:xxx:xxx)
private String ipAddress;
@Column(length = 10) // "WEB", "API", or "SYSTEM"
private String origin;
}
@@ -14,6 +14,7 @@ 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.security.model.ApiKeyAuthenticationToken;
/**
* Service for creating manual audit events throughout the application. This provides easy access to
@@ -53,7 +54,12 @@ public class AuditService {
}
String principal = getCurrentUsername();
repository.add(new AuditEvent(principal, type.name(), data));
// Add origin to the data map (captured here before async execution)
Map<String, Object> enrichedData = new java.util.HashMap<>(data);
enrichedData.put("__origin", determineOrigin());
repository.add(new AuditEvent(principal, type.name(), enrichedData));
}
/**
@@ -115,7 +121,12 @@ public class AuditService {
}
String principal = getCurrentUsername();
repository.add(new AuditEvent(principal, type, data));
// Add origin to the data map (captured here before async execution)
Map<String, Object> enrichedData = new java.util.HashMap<>(data);
enrichedData.put("__origin", determineOrigin());
repository.add(new AuditEvent(principal, type, enrichedData));
}
/**
@@ -161,9 +172,106 @@ public class AuditService {
audit(principal, type, data, AuditLevel.STANDARD);
}
/**
* Record an audit event with pre-captured principal, origin, and IP (for use by audit aspects).
*/
public void audit(
String principal,
String origin,
String ipAddress,
AuditEventType type,
Map<String, Object> data,
AuditLevel level) {
if (!auditConfig.isEnabled()
|| !auditConfig.getAuditLevel().includes(level)
|| !runningEE) {
return;
}
// Add origin and IP to the data map (already captured before async)
Map<String, Object> enrichedData = new java.util.HashMap<>(data);
enrichedData.put("__origin", origin);
if (ipAddress != null) {
enrichedData.put("__ipAddress", ipAddress);
}
repository.add(new AuditEvent(principal, type.name(), enrichedData));
}
/**
* Record an audit event with pre-captured principal, origin, and IP using string type (for
* backward compatibility).
*/
public void audit(
String principal,
String origin,
String ipAddress,
String type,
Map<String, Object> data,
AuditLevel level) {
if (!auditConfig.isEnabled()
|| !auditConfig.getAuditLevel().includes(level)
|| !runningEE) {
return;
}
// Add origin and IP to the data map (already captured before async)
Map<String, Object> enrichedData = new java.util.HashMap<>(data);
enrichedData.put("__origin", origin);
if (ipAddress != null) {
enrichedData.put("__ipAddress", ipAddress);
}
repository.add(new AuditEvent(principal, type, enrichedData));
}
/** 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";
}
/**
* Captures the current principal for later use (avoids SecurityContext issues in async/thread
* changes). Public so audit aspects can capture early before thread context changes.
*/
public String captureCurrentPrincipal() {
return getCurrentUsername();
}
/**
* Captures the current origin for later use (avoids SecurityContext issues in async/thread
* changes). Public so audit aspects can capture early before thread context changes.
*/
public String captureCurrentOrigin() {
return determineOrigin();
}
/**
* Determines the origin of the request: API (X-API-KEY), WEB (JWT), or SYSTEM (no auth).
* IMPORTANT: This must be called in the request thread before async execution.
*
* @return "API", "WEB", or "SYSTEM"
*/
private String determineOrigin() {
try {
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
// Check if authenticated via API key
if (auth instanceof ApiKeyAuthenticationToken) {
return "API";
}
// Check if authenticated via JWT (web user)
if (auth != null && auth.isAuthenticated() && !"anonymousUser".equals(auth.getName())) {
return "WEB";
}
// System or unauthenticated
return "SYSTEM";
} catch (Exception e) {
log.debug("Could not determine origin for audit event", e);
return "SYSTEM";
}
}
}