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.
* Using an enum ensures consistency in event type naming and categorization.
*/
public enum AuditEventType {
// Authentication events - BASIC level
USER_LOGIN("User login"),
USER_LOGOUT("User logout"),
USER_LOGOUT("User logout"),
USER_FAILED_LOGIN("Failed login attempt"),
// User/admin events - BASIC level
@ -17,8 +16,7 @@ public enum AuditEventType {
SETTINGS_CHANGED("System or admin settings operation"),
// File operations - STANDARD level
FILE_UPLOAD("File uploaded"),
FILE_DOWNLOAD("File downloaded"),
FILE_OPERATION("File operation"),
// PDF operations - STANDARD level
PDF_PROCESS("PDF processing operation"),

View File

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

View File

@ -2,11 +2,21 @@ 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")
@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 {

View File

@ -38,7 +38,11 @@ public interface PersistentAuditEventRepository
@Query("DELETE FROM PersistentAuditEvent e WHERE e.timestamp < ?1")
@org.springframework.data.jpa.repository.Modifying
@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
@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.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;
@ -23,6 +27,9 @@ 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.
@ -30,13 +37,11 @@ public class AuditCleanupService {
@Scheduled(fixedDelay = 1, initialDelay = 1, timeUnit = TimeUnit.DAYS)
public void cleanupOldAuditEvents() {
if (!auditConfig.isEnabled()) {
log.debug("Audit system is disabled, skipping cleanup");
return;
}
int retentionDays = auditConfig.getRetentionDays();
if (retentionDays <= 0) {
log.info("Audit retention is set to {} days, no cleanup needed", retentionDays);
return;
}
@ -44,10 +49,64 @@ public class AuditCleanupService {
try {
Instant cutoffDate = Instant.now().minus(retentionDays, ChronoUnit.DAYS);
auditRepository.deleteByTimestampBefore(cutoffDate);
log.info("Successfully cleaned up audit events older than {}", cutoffDate);
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

@ -9,7 +9,7 @@ import java.util.stream.Collectors;
public final class SecretMasker {
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() {}

View File

@ -502,8 +502,7 @@
<ul>
<li>HTTP_REQUEST - GET requests for viewing</li>
<li>PDF_PROCESS - PDF processing operations</li>
<li>FILE_UPLOAD - File uploads</li>
<li>FILE_DOWNLOAD - File downloads</li>
<li>FILE_OPERATION - File-related operations</li>
<li>SETTINGS_CHANGED - System or admin settings operations</li>
</ul>
</li>
@ -835,8 +834,8 @@
datasets: [{
label: 'Events by Type',
data: typeValues,
backgroundColor: 'rgba(54, 162, 235, 0.6)',
borderColor: 'rgba(54, 162, 235, 1)',
backgroundColor: getChartColors(typeLabels.length),
borderColor: getChartColors(typeLabels.length, 1), // Full opacity for borders
borderWidth: 1
}]
},
@ -864,14 +863,7 @@
datasets: [{
label: 'Events by User',
data: userValues,
backgroundColor: [
'rgba(54, 162, 235, 0.6)',
'rgba(255, 99, 132, 0.6)',
'rgba(255, 206, 86, 0.6)',
'rgba(75, 192, 192, 0.6)',
'rgba(153, 102, 255, 0.6)',
'rgba(255, 159, 64, 0.6)'
],
backgroundColor: getChartColors(userLabels.length),
borderWidth: 1
}]
},
@ -938,6 +930,56 @@
const loading = document.getElementById(id);
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>
</div>
</div>