This commit is contained in:
Anthony Stirling 2025-06-14 00:14:47 +01:00
parent a3ccc677b1
commit 1023edd66b
7 changed files with 142 additions and 32 deletions

View File

@ -2,12 +2,11 @@ package stirling.software.proprietary.audit;
/** /**
* Standardized audit event types for the application. * Standardized audit event types for the application.
* Using an enum ensures consistency in event type naming and categorization.
*/ */
public enum AuditEventType { public enum AuditEventType {
// Authentication events - BASIC level // Authentication events - BASIC level
USER_LOGIN("User login"), USER_LOGIN("User login"),
USER_LOGOUT("User logout"), USER_LOGOUT("User logout"),
USER_FAILED_LOGIN("Failed login attempt"), USER_FAILED_LOGIN("Failed login attempt"),
// User/admin events - BASIC level // User/admin events - BASIC level
@ -17,8 +16,7 @@ public enum AuditEventType {
SETTINGS_CHANGED("System or admin settings operation"), SETTINGS_CHANGED("System or admin settings operation"),
// File operations - STANDARD level // File operations - STANDARD level
FILE_UPLOAD("File uploaded"), FILE_OPERATION("File operation"),
FILE_DOWNLOAD("File downloaded"),
// PDF operations - STANDARD level // PDF operations - STANDARD level
PDF_PROCESS("PDF processing operation"), PDF_PROCESS("PDF processing operation"),

View File

@ -176,15 +176,12 @@ public class ControllerAuditAspect {
return AuditEventType.SETTINGS_CHANGED; return AuditEventType.SETTINGS_CHANGED;
} }
// File operations // File operations - using path prefixes to avoid false matches
else if (className.contains("file") || path.contains("file")) { else if (className.contains("file") ||
if (path.contains("upload") || path.contains("add")) { path.startsWith("/file") ||
return AuditEventType.FILE_UPLOAD; path.startsWith("/files/") ||
} else if (path.contains("download")) { path.matches("(?i).*/(upload|download)/.*")) {
return AuditEventType.FILE_DOWNLOAD; return AuditEventType.FILE_OPERATION;
} else {
return AuditEventType.FILE_UPLOAD;
}
} }
// Default to PDF operations for most controllers // Default to PDF operations for most controllers

View File

@ -2,11 +2,21 @@ package stirling.software.proprietary.model.security;
import jakarta.persistence.*; import jakarta.persistence.*;
import lombok.*; import lombok.*;
import org.hibernate.annotations.Index;
import java.time.Instant; import java.time.Instant;
@Entity @Entity
@Table(name = "audit_events") @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 @Data @Builder @NoArgsConstructor @AllArgsConstructor
public class PersistentAuditEvent { public class PersistentAuditEvent {

View File

@ -38,7 +38,11 @@ public interface PersistentAuditEventRepository
@Query("DELETE FROM PersistentAuditEvent e WHERE e.timestamp < ?1") @Query("DELETE FROM PersistentAuditEvent e WHERE e.timestamp < ?1")
@org.springframework.data.jpa.repository.Modifying @org.springframework.data.jpa.repository.Modifying
@org.springframework.transaction.annotation.Transactional @org.springframework.transaction.annotation.Transactional
void deleteByTimestampBefore(Instant cutoffDate); 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 // Stats queries
@Query("SELECT e.type, COUNT(e) FROM PersistentAuditEvent e GROUP BY e.type") @Query("SELECT e.type, COUNT(e) FROM PersistentAuditEvent e GROUP BY e.type")

View File

@ -2,10 +2,14 @@ package stirling.software.proprietary.service;
import java.time.Instant; import java.time.Instant;
import java.time.temporal.ChronoUnit; import java.time.temporal.ChronoUnit;
import java.util.List;
import java.util.concurrent.TimeUnit; 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.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
@ -23,6 +27,9 @@ public class AuditCleanupService {
private final PersistentAuditEventRepository auditRepository; private final PersistentAuditEventRepository auditRepository;
private final AuditConfigurationProperties auditConfig; 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. * Scheduled task that runs daily to clean up old audit events.
* The retention period is configurable in settings.yml. * The retention period is configurable in settings.yml.
@ -30,13 +37,11 @@ public class AuditCleanupService {
@Scheduled(fixedDelay = 1, initialDelay = 1, timeUnit = TimeUnit.DAYS) @Scheduled(fixedDelay = 1, initialDelay = 1, timeUnit = TimeUnit.DAYS)
public void cleanupOldAuditEvents() { public void cleanupOldAuditEvents() {
if (!auditConfig.isEnabled()) { if (!auditConfig.isEnabled()) {
log.debug("Audit system is disabled, skipping cleanup");
return; return;
} }
int retentionDays = auditConfig.getRetentionDays(); int retentionDays = auditConfig.getRetentionDays();
if (retentionDays <= 0) { if (retentionDays <= 0) {
log.info("Audit retention is set to {} days, no cleanup needed", retentionDays);
return; return;
} }
@ -44,10 +49,64 @@ public class AuditCleanupService {
try { try {
Instant cutoffDate = Instant.now().minus(retentionDays, ChronoUnit.DAYS); Instant cutoffDate = Instant.now().minus(retentionDays, ChronoUnit.DAYS);
auditRepository.deleteByTimestampBefore(cutoffDate); int totalDeleted = batchDeleteEvents(cutoffDate);
log.info("Successfully cleaned up audit events older than {}", cutoffDate); log.info("Successfully cleaned up {} audit events older than {}", totalDeleted, cutoffDate);
} catch (Exception e) { } catch (Exception e) {
log.error("Error cleaning up old audit events", 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

@ -9,7 +9,7 @@ import java.util.stream.Collectors;
public final class SecretMasker { public final class SecretMasker {
private static final Pattern SENSITIVE = private static final Pattern SENSITIVE =
Pattern.compile("(?i)(password|token|secret|api[_-]?key|authorization|auth)"); Pattern.compile("(?i)(password|token|secret|api[_-]?key|authorization|auth|jwt|cred|cert)");
private SecretMasker() {} private SecretMasker() {}

View File

@ -502,8 +502,7 @@
<ul> <ul>
<li>HTTP_REQUEST - GET requests for viewing</li> <li>HTTP_REQUEST - GET requests for viewing</li>
<li>PDF_PROCESS - PDF processing operations</li> <li>PDF_PROCESS - PDF processing operations</li>
<li>FILE_UPLOAD - File uploads</li> <li>FILE_OPERATION - File-related operations</li>
<li>FILE_DOWNLOAD - File downloads</li>
<li>SETTINGS_CHANGED - System or admin settings operations</li> <li>SETTINGS_CHANGED - System or admin settings operations</li>
</ul> </ul>
</li> </li>
@ -835,8 +834,8 @@
datasets: [{ datasets: [{
label: 'Events by Type', label: 'Events by Type',
data: typeValues, data: typeValues,
backgroundColor: 'rgba(54, 162, 235, 0.6)', backgroundColor: getChartColors(typeLabels.length),
borderColor: 'rgba(54, 162, 235, 1)', borderColor: getChartColors(typeLabels.length, 1), // Full opacity for borders
borderWidth: 1 borderWidth: 1
}] }]
}, },
@ -864,14 +863,7 @@
datasets: [{ datasets: [{
label: 'Events by User', label: 'Events by User',
data: userValues, data: userValues,
backgroundColor: [ backgroundColor: getChartColors(userLabels.length),
'rgba(54, 162, 235, 0.6)',
'rgba(255, 99, 132, 0.6)',
'rgba(255, 206, 86, 0.6)',
'rgba(75, 192, 192, 0.6)',
'rgba(153, 102, 255, 0.6)',
'rgba(255, 159, 64, 0.6)'
],
borderWidth: 1 borderWidth: 1
}] }]
}, },
@ -938,6 +930,56 @@
const loading = document.getElementById(id); const loading = document.getElementById(id);
if (loading) loading.style.display = 'none'; if (loading) loading.style.display = 'none';
} }
// Function to generate a palette of colors for charts
function getChartColors(count, opacity = 0.6) {
// Base colors - a larger palette than the default
const colors = [
[54, 162, 235], // blue
[255, 99, 132], // red
[75, 192, 192], // teal
[255, 206, 86], // yellow
[153, 102, 255], // purple
[255, 159, 64], // orange
[46, 204, 113], // green
[231, 76, 60], // dark red
[52, 152, 219], // light blue
[155, 89, 182], // violet
[241, 196, 15], // dark yellow
[26, 188, 156], // turquoise
[230, 126, 34], // dark orange
[149, 165, 166], // light gray
[243, 156, 18], // amber
[39, 174, 96], // emerald
[211, 84, 0], // dark orange red
[22, 160, 133], // green sea
[41, 128, 185], // belize hole
[142, 68, 173] // wisteria
];
const result = [];
// Always use the same format regardless of color source
if (count > colors.length) {
// Generate colors algorithmically for large sets
for (let i = 0; i < count; i++) {
// Generate a color based on position in the hue circle (0-360)
const hue = (i * 360 / count) % 360;
const sat = 70 + Math.random() * 10; // 70-80%
const light = 50 + Math.random() * 10; // 50-60%
result.push(`hsla(${hue}, ${sat}%, ${light}%, ${opacity})`);
}
} else {
// Use colors from our palette but also return in hsla format for consistency
for (let i = 0; i < count; i++) {
const color = colors[i % colors.length];
result.push(`rgba(${color[0]}, ${color[1]}, ${color[2]}, ${opacity})`);
}
}
return result;
}
</script> </script>
</div> </div>
</div> </div>