feat(audit): introduce structured Audit API with export, stats, and cleanup endpoints (#4217)

# Description of Changes

- Added new REST-based `AuditDashboardController` under `/api/v1/audit`
with endpoints for:
  - Audit data retrieval with pagination (`/data`)
  - Statistics retrieval (`/stats`)
  - Export in CSV and JSON (`/export/csv`, `/export/json`)
  - Cleanup of audit events before a given date (`/cleanup/before`)
  - Retrieval of distinct audit event types (`/types`)
- Extracted web dashboard logic into `AuditDashboardWebController` (view
rendering only).
- Introduced new API models:
  - `AuditDataRequest`, `AuditDataResponse`
  - `AuditExportRequest`, `AuditDateExportRequest`
  - `AuditStatsResponse`
- Extended `PersistentAuditEventRepository` with richer query methods
(histograms, counts, top/latest events, distinct principals).
- Updated `dashboard.js` to use new API endpoints under `/api/v1/audit`.
- Enhanced authentication handlers and user endpoints with `@Audited`
annotations for login/logout/password change events.
- Cleaned up `LicenseKeyChecker` by removing unused `updateLicenseKey`
method.
- Moved admin-related controllers into `controller.api` namespace with
proper OpenAPI annotations (`@Operation`, `@Tag`).
- Improved `CleanUrlInterceptor` whitelist for new query parameters
(`days`, `date`).

---

## Checklist

### General

- [x] I have read the [Contribution
Guidelines](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/CONTRIBUTING.md)
- [x] I have read the [Stirling-PDF Developer
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/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/devGuide/HowToAddNewLanguage.md)
(if applicable)
- [x] I have performed a self-review of my own code
- [x] 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/devGuide/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/devGuide/DeveloperGuide.md#6-testing)
for more details.
This commit is contained in:
Ludy 2025-08-18 13:03:57 +02:00 committed by GitHub
parent d23c2eaa30
commit 28b1b96cfb
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
18 changed files with 508 additions and 175 deletions

View File

@ -20,6 +20,8 @@ public class CleanUrlInterceptor implements HandlerInterceptor {
"endpoints", "endpoints",
"logout", "logout",
"error", "error",
"days",
"date",
"errorOAuth", "errorOAuth",
"file", "file",
"messageType", "messageType",

View File

@ -10,8 +10,12 @@ import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.DeleteMapping; import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController; import org.springframework.web.bind.annotation.RestController;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletRequest;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
@ -27,6 +31,8 @@ import stirling.software.common.service.TaskManager;
@RestController @RestController
@RequiredArgsConstructor @RequiredArgsConstructor
@Slf4j @Slf4j
@RequestMapping("/api/v1/general")
@Tag(name = "Job Management", description = "Job Management API")
public class JobController { public class JobController {
private final TaskManager taskManager; private final TaskManager taskManager;
@ -40,7 +46,8 @@ public class JobController {
* @param jobId The job ID * @param jobId The job ID
* @return The job result * @return The job result
*/ */
@GetMapping("/api/v1/general/job/{jobId}") @GetMapping("/job/{jobId}")
@Operation(summary = "Get job status")
public ResponseEntity<?> getJobStatus(@PathVariable("jobId") String jobId) { public ResponseEntity<?> getJobStatus(@PathVariable("jobId") String jobId) {
JobResult result = taskManager.getJobResult(jobId); JobResult result = taskManager.getJobResult(jobId);
if (result == null) { if (result == null) {
@ -68,7 +75,8 @@ public class JobController {
* @param jobId The job ID * @param jobId The job ID
* @return The job result * @return The job result
*/ */
@GetMapping("/api/v1/general/job/{jobId}/result") @GetMapping("/job/{jobId}/result")
@Operation(summary = "Get job result")
public ResponseEntity<?> getJobResult(@PathVariable("jobId") String jobId) { public ResponseEntity<?> getJobResult(@PathVariable("jobId") String jobId) {
JobResult result = taskManager.getJobResult(jobId); JobResult result = taskManager.getJobResult(jobId);
if (result == null) { if (result == null) {
@ -130,7 +138,8 @@ public class JobController {
* @param jobId The job ID * @param jobId The job ID
* @return Response indicating whether the job was cancelled * @return Response indicating whether the job was cancelled
*/ */
@DeleteMapping("/api/v1/general/job/{jobId}") @DeleteMapping("/job/{jobId}")
@Operation(summary = "Cancel a job")
public ResponseEntity<?> cancelJob(@PathVariable("jobId") String jobId) { public ResponseEntity<?> cancelJob(@PathVariable("jobId") String jobId) {
log.debug("Request to cancel job: {}", jobId); log.debug("Request to cancel job: {}", jobId);
@ -197,7 +206,8 @@ public class JobController {
* @param jobId The job ID * @param jobId The job ID
* @return List of files for the job * @return List of files for the job
*/ */
@GetMapping("/api/v1/general/job/{jobId}/result/files") @GetMapping("/job/{jobId}/result/files")
@Operation(summary = "Get job result files")
public ResponseEntity<?> getJobFiles(@PathVariable("jobId") String jobId) { public ResponseEntity<?> getJobFiles(@PathVariable("jobId") String jobId) {
JobResult result = taskManager.getJobResult(jobId); JobResult result = taskManager.getJobResult(jobId);
if (result == null) { if (result == null) {
@ -226,7 +236,8 @@ public class JobController {
* @param fileId The file ID * @param fileId The file ID
* @return The file metadata * @return The file metadata
*/ */
@GetMapping("/api/v1/general/files/{fileId}/metadata") @GetMapping("/files/{fileId}/metadata")
@Operation(summary = "Get file metadata")
public ResponseEntity<?> getFileMetadata(@PathVariable("fileId") String fileId) { public ResponseEntity<?> getFileMetadata(@PathVariable("fileId") String fileId) {
try { try {
// Verify file exists // Verify file exists
@ -266,7 +277,8 @@ public class JobController {
* @param fileId The file ID * @param fileId The file ID
* @return The file content * @return The file content
*/ */
@GetMapping("/api/v1/general/files/{fileId}") @GetMapping("/files/{fileId}")
@Operation(summary = "Download a file")
public ResponseEntity<?> downloadFile(@PathVariable("fileId") String fileId) { public ResponseEntity<?> downloadFile(@PathVariable("fileId") String fileId) {
try { try {
// Verify file exists // Verify file exists

View File

@ -1,4 +1,4 @@
package stirling.software.proprietary.controller; package stirling.software.proprietary.controller.api;
import java.util.Map; import java.util.Map;
@ -6,8 +6,12 @@ import org.springframework.http.ResponseEntity;
import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController; import org.springframework.web.bind.annotation.RestController;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
@ -22,6 +26,9 @@ import stirling.software.common.service.TaskManager;
@RestController @RestController
@RequiredArgsConstructor @RequiredArgsConstructor
@Slf4j @Slf4j
@RequestMapping("/api/v1/admin")
@PreAuthorize("hasRole('ROLE_ADMIN')")
@Tag(name = "Admin Job Management", description = "Admin-only Job Management APIs")
public class AdminJobController { public class AdminJobController {
private final TaskManager taskManager; private final TaskManager taskManager;
@ -32,7 +39,8 @@ public class AdminJobController {
* *
* @return Job statistics * @return Job statistics
*/ */
@GetMapping("/api/v1/admin/job/stats") @GetMapping("/job/stats")
@Operation(summary = "Get job statistics")
@PreAuthorize("hasRole('ROLE_ADMIN')") @PreAuthorize("hasRole('ROLE_ADMIN')")
public ResponseEntity<JobStats> getJobStats() { public ResponseEntity<JobStats> getJobStats() {
JobStats stats = taskManager.getJobStats(); JobStats stats = taskManager.getJobStats();
@ -48,7 +56,8 @@ public class AdminJobController {
* *
* @return Queue statistics * @return Queue statistics
*/ */
@GetMapping("/api/v1/admin/job/queue/stats") @GetMapping("/job/queue/stats")
@Operation(summary = "Get job queue statistics")
@PreAuthorize("hasRole('ROLE_ADMIN')") @PreAuthorize("hasRole('ROLE_ADMIN')")
public ResponseEntity<?> getQueueStats() { public ResponseEntity<?> getQueueStats() {
Map<String, Object> queueStats = jobQueue.getQueueStats(); Map<String, Object> queueStats = jobQueue.getQueueStats();
@ -61,7 +70,8 @@ public class AdminJobController {
* *
* @return A response indicating how many jobs were cleaned up * @return A response indicating how many jobs were cleaned up
*/ */
@PostMapping("/api/v1/admin/job/cleanup") @PostMapping("/job/cleanup")
@Operation(summary = "Cleanup old jobs")
@PreAuthorize("hasRole('ROLE_ADMIN')") @PreAuthorize("hasRole('ROLE_ADMIN')")
public ResponseEntity<?> cleanupOldJobs() { public ResponseEntity<?> cleanupOldJobs() {
int beforeCount = taskManager.getJobStats().getTotalJobs(); int beforeCount = taskManager.getJobStats().getTotalJobs();

View File

@ -1,4 +1,4 @@
package stirling.software.proprietary.controller; package stirling.software.proprietary.controller.api;
import java.time.Instant; import java.time.Instant;
import java.time.LocalDate; import java.time.LocalDate;
@ -6,13 +6,13 @@ import java.time.LocalDateTime;
import java.time.ZoneId; import java.time.ZoneId;
import java.time.format.DateTimeFormatter; import java.time.format.DateTimeFormatter;
import java.util.Arrays; import java.util.Arrays;
import java.util.HashMap;
import java.util.HashSet; import java.util.HashSet;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.Set; import java.util.Set;
import java.util.stream.Collectors; import java.util.stream.Collectors;
import org.springdoc.core.annotations.ParameterObject;
import org.springframework.data.domain.Page; import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageRequest; import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Pageable; import org.springframework.data.domain.Pageable;
@ -22,133 +22,101 @@ import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType; import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity; import org.springframework.http.ResponseEntity;
import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.stereotype.Controller; import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.ResponseBody; import org.springframework.web.bind.annotation.RestController;
import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.ObjectMapper;
import jakarta.servlet.http.HttpServletRequest; import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.media.Schema;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import stirling.software.proprietary.audit.AuditEventType; import stirling.software.proprietary.audit.AuditEventType;
import stirling.software.proprietary.audit.AuditLevel; import stirling.software.proprietary.model.api.audit.AuditDataRequest;
import stirling.software.proprietary.config.AuditConfigurationProperties; import stirling.software.proprietary.model.api.audit.AuditDataResponse;
import stirling.software.proprietary.model.api.audit.AuditExportRequest;
import stirling.software.proprietary.model.api.audit.AuditStatsResponse;
import stirling.software.proprietary.model.security.PersistentAuditEvent; import stirling.software.proprietary.model.security.PersistentAuditEvent;
import stirling.software.proprietary.repository.PersistentAuditEventRepository; import stirling.software.proprietary.repository.PersistentAuditEventRepository;
import stirling.software.proprietary.security.config.EnterpriseEndpoint; import stirling.software.proprietary.security.config.EnterpriseEndpoint;
/** Controller for the audit dashboard. Admin-only access. */ /** REST endpoints for the audit dashboard. */
@Slf4j @Slf4j
@Controller @RestController
@RequestMapping("/audit") @RequestMapping("/api/v1/audit")
@PreAuthorize("hasRole('ADMIN')") @PreAuthorize("hasRole('ROLE_ADMIN')")
@RequiredArgsConstructor @RequiredArgsConstructor
@EnterpriseEndpoint @EnterpriseEndpoint
@Tag(name = "Audit", description = "Only Enterprise - Audit related operations")
public class AuditDashboardController { public class AuditDashboardController {
private final PersistentAuditEventRepository auditRepository; private final PersistentAuditEventRepository auditRepository;
private final AuditConfigurationProperties auditConfig;
private final ObjectMapper objectMapper; 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. */ /** Get audit events data for the dashboard tables. */
@GetMapping("/data") @GetMapping("/data")
@ResponseBody @Operation(summary = "Get audit events data")
public Map<String, Object> getAuditData( public AuditDataResponse getAuditData(@ParameterObject AuditDataRequest request) {
@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()); Pageable pageable =
PageRequest.of(
request.getPage(), request.getSize(), Sort.by("timestamp").descending());
Page<PersistentAuditEvent> events; Page<PersistentAuditEvent> events;
String mode; String type = request.getType();
String principal = request.getPrincipal();
LocalDate startDate = request.getStartDate();
LocalDate endDate = request.getEndDate();
if (type != null && principal != null && startDate != null && endDate != null) { if (type != null && principal != null && startDate != null && endDate != null) {
mode = "principal + type + startDate + endDate";
Instant start = startDate.atStartOfDay(ZoneId.systemDefault()).toInstant(); Instant start = startDate.atStartOfDay(ZoneId.systemDefault()).toInstant();
Instant end = endDate.plusDays(1).atStartOfDay(ZoneId.systemDefault()).toInstant(); Instant end = endDate.plusDays(1).atStartOfDay(ZoneId.systemDefault()).toInstant();
events = events =
auditRepository.findByPrincipalAndTypeAndTimestampBetween( auditRepository.findByPrincipalAndTypeAndTimestampBetween(
principal, type, start, end, pageable); principal, type, start, end, pageable);
} else if (type != null && principal != null) { } else if (type != null && principal != null) {
mode = "principal + type";
events = auditRepository.findByPrincipalAndType(principal, type, pageable); events = auditRepository.findByPrincipalAndType(principal, type, pageable);
} else if (type != null && startDate != null && endDate != null) { } else if (type != null && startDate != null && endDate != null) {
mode = "type + startDate + endDate";
Instant start = startDate.atStartOfDay(ZoneId.systemDefault()).toInstant(); Instant start = startDate.atStartOfDay(ZoneId.systemDefault()).toInstant();
Instant end = endDate.plusDays(1).atStartOfDay(ZoneId.systemDefault()).toInstant(); Instant end = endDate.plusDays(1).atStartOfDay(ZoneId.systemDefault()).toInstant();
events = auditRepository.findByTypeAndTimestampBetween(type, start, end, pageable); events = auditRepository.findByTypeAndTimestampBetween(type, start, end, pageable);
} else if (principal != null && startDate != null && endDate != null) { } else if (principal != null && startDate != null && endDate != null) {
mode = "principal + startDate + endDate";
Instant start = startDate.atStartOfDay(ZoneId.systemDefault()).toInstant(); Instant start = startDate.atStartOfDay(ZoneId.systemDefault()).toInstant();
Instant end = endDate.plusDays(1).atStartOfDay(ZoneId.systemDefault()).toInstant(); Instant end = endDate.plusDays(1).atStartOfDay(ZoneId.systemDefault()).toInstant();
events = events =
auditRepository.findByPrincipalAndTimestampBetween( auditRepository.findByPrincipalAndTimestampBetween(
principal, start, end, pageable); principal, start, end, pageable);
} else if (startDate != null && endDate != null) { } else if (startDate != null && endDate != null) {
mode = "startDate + endDate";
Instant start = startDate.atStartOfDay(ZoneId.systemDefault()).toInstant(); Instant start = startDate.atStartOfDay(ZoneId.systemDefault()).toInstant();
Instant end = endDate.plusDays(1).atStartOfDay(ZoneId.systemDefault()).toInstant(); Instant end = endDate.plusDays(1).atStartOfDay(ZoneId.systemDefault()).toInstant();
events = auditRepository.findByTimestampBetween(start, end, pageable); events = auditRepository.findByTimestampBetween(start, end, pageable);
} else if (type != null) { } else if (type != null) {
mode = "type";
events = auditRepository.findByType(type, pageable); events = auditRepository.findByType(type, pageable);
} else if (principal != null) { } else if (principal != null) {
mode = "principal";
events = auditRepository.findByPrincipal(principal, pageable); events = auditRepository.findByPrincipal(principal, pageable);
} else { } else {
mode = "all";
events = auditRepository.findAll(pageable); events = auditRepository.findAll(pageable);
} }
// Logging // Logging
List<PersistentAuditEvent> content = events.getContent(); List<PersistentAuditEvent> content = events.getContent();
Map<String, Object> response = new HashMap<>(); return new AuditDataResponse(
response.put("content", content); content, events.getTotalPages(), events.getTotalElements(), events.getNumber());
response.put("totalPages", events.getTotalPages());
response.put("totalElements", events.getTotalElements());
response.put("currentPage", events.getNumber());
return response;
} }
/** Get statistics for charts. */ /** Get statistics for charts (last X days). Existing behavior preserved. */
@GetMapping("/stats") @GetMapping("/stats")
@ResponseBody @Operation(summary = "Get audit statistics for the last N days")
public Map<String, Object> getAuditStats( public AuditStatsResponse getAuditStats(
@Schema(description = "Number of days to look back for audit events", example = "7", required = true)
@RequestParam(value = "days", defaultValue = "7") int days) { @RequestParam(value = "days", defaultValue = "7") int days) {
// Get events from the last X days // Get events from the last X days
@ -181,18 +149,53 @@ public class AuditDashboardController {
.format(DateTimeFormatter.ISO_LOCAL_DATE), .format(DateTimeFormatter.ISO_LOCAL_DATE),
Collectors.counting())); Collectors.counting()));
Map<String, Object> stats = new HashMap<>(); return new AuditStatsResponse(eventsByType, eventsByPrincipal, eventsByDay, events.size());
stats.put("eventsByType", eventsByType);
stats.put("eventsByPrincipal", eventsByPrincipal);
stats.put("eventsByDay", eventsByDay);
stats.put("totalEvents", events.size());
return stats;
} }
// /** Advanced statistics using repository aggregations, with explicit date range. */
// @GetMapping("/stats/range")
// @Operation(summary = "Get audit statistics for a date range (aggregated in DB)")
// public Map<String, Object> getAuditStatsRange(@ParameterObject AuditDateExportRequest
// request) {
// LocalDate startDate = request.getStartDate();
// LocalDate endDate = request.getEndDate();
// Instant start = startDate.atStartOfDay(ZoneId.systemDefault()).toInstant();
// Instant end = endDate.plusDays(1).atStartOfDay(ZoneId.systemDefault()).toInstant();
// Map<String, Long> byType = toStringLongMap(auditRepository.countByTypeBetween(start,
// end));
// Map<String, Long> byPrincipal =
// toStringLongMap(auditRepository.countByPrincipalBetween(start, end));
// Map<String, Long> byDay = new HashMap<>();
// for (Object[] row : auditRepository.histogramByDayBetween(start, end)) {
// int y = ((Number) row[0]).intValue();
// int m = ((Number) row[1]).intValue();
// int d = ((Number) row[2]).intValue();
// long count = ((Number) row[3]).longValue();
// String key = String.format("%04d-%02d-%02d", y, m, d);
// byDay.put(key, count);
// }
// Map<String, Long> byHour = new HashMap<>();
// for (Object[] row : auditRepository.histogramByHourBetween(start, end)) {
// int hour = ((Number) row[0]).intValue();
// long count = ((Number) row[1]).longValue();
// byHour.put(String.format("%02d:00", hour), count);
// }
// Map<String, Object> payload = new HashMap<>();
// payload.put("byType", byType);
// payload.put("byPrincipal", byPrincipal);
// payload.put("byDay", byDay);
// payload.put("byHour", byHour);
// return payload;
// }
/** Get all unique event types from the database for filtering. */ /** Get all unique event types from the database for filtering. */
@GetMapping("/types") @GetMapping("/types")
@ResponseBody @Operation(summary = "Get all unique audit event types")
public List<String> getAuditTypes() { public List<String> getAuditTypes() {
// Get distinct event types from the database // Get distinct event types from the database
List<String> dbTypes = auditRepository.findDistinctEventTypes(); List<String> dbTypes = auditRepository.findDistinctEventTypes();
@ -212,49 +215,11 @@ public class AuditDashboardController {
} }
/** Export audit data as CSV. */ /** Export audit data as CSV. */
@GetMapping("/export") @GetMapping("/export/csv")
public ResponseEntity<byte[]> exportAuditData( @Operation(summary = "Export audit data as CSV")
@RequestParam(value = "type", required = false) String type, public ResponseEntity<byte[]> exportAuditData(@ParameterObject AuditExportRequest request) {
@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 = getAuditEventsByCriteria(request);
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 // Convert to CSV
StringBuilder csv = new StringBuilder(); StringBuilder csv = new StringBuilder();
@ -282,15 +247,113 @@ public class AuditDashboardController {
/** Export audit data as JSON. */ /** Export audit data as JSON. */
@GetMapping("/export/json") @GetMapping("/export/json")
public ResponseEntity<byte[]> exportAuditDataJson( @Operation(summary = "Export audit data as JSON")
@RequestParam(value = "type", required = false) String type, public ResponseEntity<byte[]> exportAuditDataJson(@ParameterObject AuditExportRequest request) {
@RequestParam(value = "principal", required = false) String principal,
@RequestParam(value = "startDate", required = false) List<PersistentAuditEvent> events = getAuditEventsByCriteria(request);
// 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();
}
}
// /** Get all unique principals. */
// @GetMapping("/principals")
// @Operation(summary = "Get all distinct principals")
// public List<String> getPrincipals() {
// return auditRepository.findDistinctPrincipals();
// }
// /** Get principals by event type. */
// @GetMapping("/types/{type}/principals")
// @Operation(summary = "Get distinct principals for a given type")
// public List<String> getPrincipalsByType(@PathVariable("type") String type) {
// return auditRepository.findDistinctPrincipalsByType(type);
// }
// /** Latest helpers */
// @GetMapping("/latest")
// @Operation(summary = "Get the latest audit event, optionally filtered by type or principal")
// public ResponseEntity<PersistentAuditEvent> getLatest(
// @RequestParam(value = "type", required = false) String type,
// @RequestParam(value = "principal", required = false) String principal) {
// if (type != null) {
// return auditRepository
// .findTopByTypeOrderByTimestampDesc(type)
// .map(ResponseEntity::ok)
// .orElse(ResponseEntity.noContent().build());
// } else if (principal != null) {
// return auditRepository
// .findTopByPrincipalOrderByTimestampDesc(principal)
// .map(ResponseEntity::ok)
// .orElse(ResponseEntity.noContent().build());
// }
// return auditRepository
// .findTopByOrderByTimestampDesc()
// .map(ResponseEntity::ok)
// .orElse(ResponseEntity.noContent().build());
// }
/** Cleanup endpoints data before a certain date */
@DeleteMapping("/cleanup/before")
@Operation(
summary = "Cleanup audit events before a certain date",
description = "Deletes all audit events before the specified date.")
public Map<String, Object> cleanupBefore(
@RequestParam(value = "date", required = true)
@Schema(
description = "The cutoff date for cleanup",
example = "2025-01-01",
format = "date")
@DateTimeFormat(iso = DateTimeFormat.ISO.DATE) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE)
LocalDate startDate, LocalDate date) {
@RequestParam(value = "endDate", required = false) if (date != null && !date.isAfter(LocalDate.now())) {
@DateTimeFormat(iso = DateTimeFormat.ISO.DATE) Instant cutoff = date.atStartOfDay(ZoneId.systemDefault()).toInstant();
LocalDate endDate) { int deleted = auditRepository.deleteByTimestampBefore(cutoff);
return Map.of("deleted", deleted, "cutoffDate", date.toString());
}
return Map.of(
"error",
"Invalid date format. Use ISO date format (YYYY-MM-DD). Date must be in the past.");
}
// // ===== Helpers =====
// private Map<String, Long> toStringLongMap(List<Object[]> rows) {
// Map<String, Long> map = new HashMap<>();
// for (Object[] row : rows) {
// String key = String.valueOf(row[0]);
// long val = ((Number) row[1]).longValue();
// map.put(key, val);
// }
// return map;
// }
/** 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("\"", "\"\"") + "\"";
}
private List<PersistentAuditEvent> getAuditEventsByCriteria(AuditExportRequest request) {
String type = request.getType();
String principal = request.getPrincipal();
LocalDate startDate = request.getStartDate();
LocalDate endDate = request.getEndDate();
// Get data with same filtering as getAuditData // Get data with same filtering as getAuditData
List<PersistentAuditEvent> events; List<PersistentAuditEvent> events;
@ -324,29 +387,6 @@ public class AuditDashboardController {
} else { } else {
events = auditRepository.findAll(); events = auditRepository.findAll();
} }
return events;
// 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,41 @@
package stirling.software.proprietary.controller.web;
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 io.swagger.v3.oas.annotations.Hidden;
import lombok.RequiredArgsConstructor;
import stirling.software.proprietary.audit.AuditEventType;
import stirling.software.proprietary.audit.AuditLevel;
import stirling.software.proprietary.config.AuditConfigurationProperties;
import stirling.software.proprietary.security.config.EnterpriseEndpoint;
@Controller
@PreAuthorize("hasRole('ADMIN')")
@RequiredArgsConstructor
@EnterpriseEndpoint
public class AuditDashboardWebController {
private final AuditConfigurationProperties auditConfig;
/** Display the audit dashboard. */
@GetMapping("/audit")
@Hidden
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";
}
}

View File

@ -0,0 +1,21 @@
package stirling.software.proprietary.model.api.audit;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import lombok.EqualsAndHashCode;
import stirling.software.proprietary.security.config.EnterpriseEndpoint;
/** Request object used for querying audit events. */
@Data
@EnterpriseEndpoint
@EqualsAndHashCode(callSuper = true)
public class AuditDataRequest extends AuditExportRequest {
@Schema(description = "Page number for pagination", example = "0", defaultValue = "0")
private int page = 0;
@Schema(description = "Page size for pagination", example = "30", defaultValue = "30")
private int size = 30;
}

View File

@ -0,0 +1,34 @@
package stirling.software.proprietary.model.api.audit;
import java.util.List;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.EqualsAndHashCode;
import lombok.NoArgsConstructor;
import stirling.software.proprietary.model.security.PersistentAuditEvent;
import stirling.software.proprietary.security.config.EnterpriseEndpoint;
/** Response object returned when querying audit data. */
@Data
@EnterpriseEndpoint
@NoArgsConstructor
@AllArgsConstructor
@EqualsAndHashCode
public class AuditDataResponse {
@Schema(description = "List of audit events matching the query")
private List<PersistentAuditEvent> content;
@Schema(description = "Total number of pages available", example = "5")
private int totalPages;
@Schema(description = "Total number of events", example = "150")
private long totalElements;
@Schema(description = "Current page index", example = "0")
private int currentPage;
}

View File

@ -0,0 +1,30 @@
package stirling.software.proprietary.model.api.audit;
import java.time.LocalDate;
import org.springframework.format.annotation.DateTimeFormat;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.EqualsAndHashCode;
import lombok.NoArgsConstructor;
import stirling.software.proprietary.security.config.EnterpriseEndpoint;
@Data
@EnterpriseEndpoint
@NoArgsConstructor
@AllArgsConstructor
@EqualsAndHashCode
public class AuditDateExportRequest {
@DateTimeFormat(iso = DateTimeFormat.ISO.DATE)
@Schema(description = "Start date for the export range", example = "2025-01-01")
private LocalDate startDate;
@DateTimeFormat(iso = DateTimeFormat.ISO.DATE)
@Schema(description = "End date for the export range", example = "2025-12-31")
private LocalDate endDate;
}

View File

@ -0,0 +1,37 @@
package stirling.software.proprietary.model.api.audit;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.EqualsAndHashCode;
import lombok.NoArgsConstructor;
import stirling.software.proprietary.security.config.EnterpriseEndpoint;
/** Request object used for exporting audit data with filters. */
@Data
@EnterpriseEndpoint
@NoArgsConstructor
@AllArgsConstructor
@EqualsAndHashCode(callSuper = true)
public class AuditExportRequest extends AuditDateExportRequest {
@Schema(
description = "Audit event type to filter by",
example = "USER_LOGIN",
allowableValues = {
"USER_LOGIN",
"USER_LOGOUT",
"USER_FAILED_LOGIN",
"USER_PROFILE_UPDATE",
"SETTINGS_CHANGED",
"FILE_OPERATION",
"PDF_PROCESS",
"HTTP_REQUEST"
})
private String type;
@Schema(description = "Principal (username) to filter by", example = "admin")
private String principal;
}

View File

@ -0,0 +1,33 @@
package stirling.software.proprietary.model.api.audit;
import java.util.Map;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.EqualsAndHashCode;
import lombok.NoArgsConstructor;
import stirling.software.proprietary.security.config.EnterpriseEndpoint;
/** Response object for audit statistics. */
@Data
@EnterpriseEndpoint
@NoArgsConstructor
@AllArgsConstructor
@EqualsAndHashCode
public class AuditStatsResponse {
@Schema(description = "Count of events grouped by type")
private Map<String, Long> eventsByType;
@Schema(description = "Count of events grouped by principal")
private Map<String, Long> eventsByPrincipal;
@Schema(description = "Count of events grouped by day")
private Map<String, Long> eventsByDay;
@Schema(description = "Total number of events in the period", example = "42")
private int totalEvents;
}

View File

@ -2,6 +2,7 @@ package stirling.software.proprietary.repository;
import java.time.Instant; import java.time.Instant;
import java.util.List; import java.util.List;
import java.util.Optional;
import org.springframework.data.domain.Page; import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable; import org.springframework.data.domain.Pageable;
@ -19,7 +20,8 @@ public interface PersistentAuditEventRepository extends JpaRepository<Persistent
// Basic queries // Basic queries
@Query( @Query(
"SELECT e FROM PersistentAuditEvent e WHERE UPPER(e.principal) LIKE UPPER(CONCAT('%', :principal, '%'))") "SELECT e FROM PersistentAuditEvent e WHERE UPPER(e.principal) LIKE UPPER(CONCAT('%',"
+ " :principal, '%'))")
Page<PersistentAuditEvent> findByPrincipal( Page<PersistentAuditEvent> findByPrincipal(
@Param("principal") String principal, Pageable pageable); @Param("principal") String principal, Pageable pageable);
@ -29,12 +31,14 @@ public interface PersistentAuditEventRepository extends JpaRepository<Persistent
Instant startDate, Instant endDate, Pageable pageable); Instant startDate, Instant endDate, Pageable pageable);
@Query( @Query(
"SELECT e FROM PersistentAuditEvent e WHERE UPPER(e.principal) LIKE UPPER(CONCAT('%', :principal, '%')) AND e.type = :type") "SELECT e FROM PersistentAuditEvent e WHERE UPPER(e.principal) LIKE UPPER(CONCAT('%',"
+ " :principal, '%')) AND e.type = :type")
Page<PersistentAuditEvent> findByPrincipalAndType( Page<PersistentAuditEvent> findByPrincipalAndType(
@Param("principal") String principal, @Param("type") String type, Pageable pageable); @Param("principal") String principal, @Param("type") String type, Pageable pageable);
@Query( @Query(
"SELECT e FROM PersistentAuditEvent e WHERE UPPER(e.principal) LIKE UPPER(CONCAT('%', :principal, '%')) AND e.timestamp BETWEEN :startDate AND :endDate") "SELECT e FROM PersistentAuditEvent e WHERE UPPER(e.principal) LIKE UPPER(CONCAT('%',"
+ " :principal, '%')) AND e.timestamp BETWEEN :startDate AND :endDate")
Page<PersistentAuditEvent> findByPrincipalAndTimestampBetween( Page<PersistentAuditEvent> findByPrincipalAndTimestampBetween(
@Param("principal") String principal, @Param("principal") String principal,
@Param("startDate") Instant startDate, @Param("startDate") Instant startDate,
@ -45,7 +49,9 @@ public interface PersistentAuditEventRepository extends JpaRepository<Persistent
String type, Instant startDate, Instant endDate, Pageable pageable); String type, Instant startDate, Instant endDate, Pageable pageable);
@Query( @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") "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( Page<PersistentAuditEvent> findByPrincipalAndTypeAndTimestampBetween(
@Param("principal") String principal, @Param("principal") String principal,
@Param("type") String type, @Param("type") String type,
@ -55,7 +61,8 @@ public interface PersistentAuditEventRepository extends JpaRepository<Persistent
// Non-paged versions for export // Non-paged versions for export
@Query( @Query(
"SELECT e FROM PersistentAuditEvent e WHERE UPPER(e.principal) LIKE UPPER(CONCAT('%', :principal, '%'))") "SELECT e FROM PersistentAuditEvent e WHERE UPPER(e.principal) LIKE UPPER(CONCAT('%',"
+ " :principal, '%'))")
List<PersistentAuditEvent> findAllByPrincipalForExport(@Param("principal") String principal); List<PersistentAuditEvent> findAllByPrincipalForExport(@Param("principal") String principal);
@Query("SELECT e FROM PersistentAuditEvent e WHERE e.type = :type") @Query("SELECT e FROM PersistentAuditEvent e WHERE e.type = :type")
@ -69,26 +76,31 @@ public interface PersistentAuditEventRepository extends JpaRepository<Persistent
List<PersistentAuditEvent> findByTimestampAfter(@Param("startDate") Instant startDate); List<PersistentAuditEvent> findByTimestampAfter(@Param("startDate") Instant startDate);
@Query( @Query(
"SELECT e FROM PersistentAuditEvent e WHERE UPPER(e.principal) LIKE UPPER(CONCAT('%', :principal, '%')) AND e.type = :type") "SELECT e FROM PersistentAuditEvent e WHERE UPPER(e.principal) LIKE UPPER(CONCAT('%',"
+ " :principal, '%')) AND e.type = :type")
List<PersistentAuditEvent> findAllByPrincipalAndTypeForExport( List<PersistentAuditEvent> findAllByPrincipalAndTypeForExport(
@Param("principal") String principal, @Param("type") String type); @Param("principal") String principal, @Param("type") String type);
@Query( @Query(
"SELECT e FROM PersistentAuditEvent e WHERE UPPER(e.principal) LIKE UPPER(CONCAT('%', :principal, '%')) AND e.timestamp BETWEEN :startDate AND :endDate") "SELECT e FROM PersistentAuditEvent e WHERE UPPER(e.principal) LIKE UPPER(CONCAT('%',"
+ " :principal, '%')) AND e.timestamp BETWEEN :startDate AND :endDate")
List<PersistentAuditEvent> findAllByPrincipalAndTimestampBetweenForExport( List<PersistentAuditEvent> findAllByPrincipalAndTimestampBetweenForExport(
@Param("principal") String principal, @Param("principal") String principal,
@Param("startDate") Instant startDate, @Param("startDate") Instant startDate,
@Param("endDate") Instant endDate); @Param("endDate") Instant endDate);
@Query( @Query(
"SELECT e FROM PersistentAuditEvent e WHERE e.type = :type AND e.timestamp BETWEEN :startDate AND :endDate") "SELECT e FROM PersistentAuditEvent e WHERE e.type = :type AND e.timestamp BETWEEN"
+ " :startDate AND :endDate")
List<PersistentAuditEvent> findAllByTypeAndTimestampBetweenForExport( List<PersistentAuditEvent> findAllByTypeAndTimestampBetweenForExport(
@Param("type") String type, @Param("type") String type,
@Param("startDate") Instant startDate, @Param("startDate") Instant startDate,
@Param("endDate") Instant endDate); @Param("endDate") Instant endDate);
@Query( @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") "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( List<PersistentAuditEvent> findAllByPrincipalAndTypeAndTimestampBetweenForExport(
@Param("principal") String principal, @Param("principal") String principal,
@Param("type") String type, @Param("type") String type,
@ -112,7 +124,51 @@ public interface PersistentAuditEventRepository extends JpaRepository<Persistent
@Query("SELECT e.principal, COUNT(e) FROM PersistentAuditEvent e GROUP BY e.principal") @Query("SELECT e.principal, COUNT(e) FROM PersistentAuditEvent e GROUP BY e.principal")
List<Object[]> countByPrincipal(); List<Object[]> countByPrincipal();
@Query(
"SELECT e.type, COUNT(e) FROM PersistentAuditEvent e WHERE e.timestamp BETWEEN"
+ " :startDate AND :endDate GROUP BY e.type")
List<Object[]> countByTypeBetween(
@Param("startDate") Instant startDate, @Param("endDate") Instant endDate);
@Query(
"SELECT e.principal, COUNT(e) FROM PersistentAuditEvent e WHERE e.timestamp BETWEEN"
+ " :startDate AND :endDate GROUP BY e.principal")
List<Object[]> countByPrincipalBetween(
@Param("startDate") Instant startDate, @Param("endDate") Instant endDate);
// Portable time-bucketing using YEAR/MONTH/DAY functions (works across most dialects)
@Query(
"SELECT YEAR(e.timestamp), MONTH(e.timestamp), DAY(e.timestamp), COUNT(e) "
+ "FROM PersistentAuditEvent e "
+ "WHERE e.timestamp BETWEEN :startDate AND :endDate "
+ "GROUP BY YEAR(e.timestamp), MONTH(e.timestamp), DAY(e.timestamp) "
+ "ORDER BY YEAR(e.timestamp), MONTH(e.timestamp), DAY(e.timestamp)")
List<Object[]> histogramByDayBetween(
@Param("startDate") Instant startDate, @Param("endDate") Instant endDate);
@Query(
"SELECT HOUR(e.timestamp), COUNT(e) FROM PersistentAuditEvent e WHERE e.timestamp"
+ " BETWEEN :startDate AND :endDate GROUP BY HOUR(e.timestamp) ORDER BY"
+ " HOUR(e.timestamp)")
List<Object[]> histogramByHourBetween(
@Param("startDate") Instant startDate, @Param("endDate") Instant endDate);
// Get distinct event types for filtering // Get distinct event types for filtering
@Query("SELECT DISTINCT e.type FROM PersistentAuditEvent e ORDER BY e.type") @Query("SELECT DISTINCT e.type FROM PersistentAuditEvent e ORDER BY e.type")
List<String> findDistinctEventTypes(); List<String> findDistinctEventTypes();
@Query("SELECT DISTINCT e.principal FROM PersistentAuditEvent e ORDER BY e.principal")
List<String> findDistinctPrincipals();
@Query(
"SELECT DISTINCT e.principal FROM PersistentAuditEvent e WHERE e.type = :type ORDER BY"
+ " e.principal")
List<String> findDistinctPrincipalsByType(@Param("type") String type);
// Top/Latest helpers & existence checks
Optional<PersistentAuditEvent> findTopByOrderByTimestampDesc();
Optional<PersistentAuditEvent> findTopByPrincipalOrderByTimestampDesc(String principal);
Optional<PersistentAuditEvent> findTopByTypeOrderByTimestampDesc(String type);
} }

View File

@ -11,7 +11,6 @@ import org.springframework.stereotype.Component;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import stirling.software.common.model.ApplicationProperties; import stirling.software.common.model.ApplicationProperties;
import stirling.software.common.util.GeneralUtils;
import stirling.software.proprietary.security.configuration.ee.KeygenLicenseVerifier.License; import stirling.software.proprietary.security.configuration.ee.KeygenLicenseVerifier.License;
@Slf4j @Slf4j
@ -86,12 +85,6 @@ public class LicenseKeyChecker {
return keyOrFilePath; return keyOrFilePath;
} }
public void updateLicenseKey(String newKey) throws IOException {
applicationProperties.getPremium().setKey(newKey);
GeneralUtils.saveKeyToSettings("EnterpriseEdition.key", newKey);
checkLicense();
}
public License getPremiumLicenseEnabledResult() { public License getPremiumLicenseEnabledResult() {
return premiumEnabledResult; return premiumEnabledResult;
} }

View File

@ -33,6 +33,9 @@ import lombok.extern.slf4j.Slf4j;
import stirling.software.common.model.ApplicationProperties; import stirling.software.common.model.ApplicationProperties;
import stirling.software.common.model.enumeration.Role; import stirling.software.common.model.enumeration.Role;
import stirling.software.common.model.exception.UnsupportedProviderException; import stirling.software.common.model.exception.UnsupportedProviderException;
import stirling.software.proprietary.audit.AuditEventType;
import stirling.software.proprietary.audit.AuditLevel;
import stirling.software.proprietary.audit.Audited;
import stirling.software.proprietary.model.Team; import stirling.software.proprietary.model.Team;
import stirling.software.proprietary.security.database.repository.UserRepository; import stirling.software.proprietary.security.database.repository.UserRepository;
import stirling.software.proprietary.security.model.AuthenticationType; import stirling.software.proprietary.security.model.AuthenticationType;
@ -82,6 +85,7 @@ public class UserController {
@PreAuthorize("!hasAuthority('ROLE_DEMO_USER')") @PreAuthorize("!hasAuthority('ROLE_DEMO_USER')")
@PostMapping("/change-username") @PostMapping("/change-username")
@Audited(type = AuditEventType.USER_PROFILE_UPDATE, level = AuditLevel.BASIC)
public RedirectView changeUsername( public RedirectView changeUsername(
Principal principal, Principal principal,
@RequestParam(name = "currentPasswordChangeUsername") String currentPassword, @RequestParam(name = "currentPasswordChangeUsername") String currentPassword,
@ -125,6 +129,7 @@ public class UserController {
@PreAuthorize("!hasAuthority('ROLE_DEMO_USER')") @PreAuthorize("!hasAuthority('ROLE_DEMO_USER')")
@PostMapping("/change-password-on-login") @PostMapping("/change-password-on-login")
@Audited(type = AuditEventType.USER_PROFILE_UPDATE, level = AuditLevel.BASIC)
public RedirectView changePasswordOnLogin( public RedirectView changePasswordOnLogin(
Principal principal, Principal principal,
@RequestParam(name = "currentPassword") String currentPassword, @RequestParam(name = "currentPassword") String currentPassword,
@ -153,6 +158,7 @@ public class UserController {
@PreAuthorize("!hasAuthority('ROLE_DEMO_USER')") @PreAuthorize("!hasAuthority('ROLE_DEMO_USER')")
@PostMapping("/change-password") @PostMapping("/change-password")
@Audited(type = AuditEventType.USER_PROFILE_UPDATE, level = AuditLevel.BASIC)
public RedirectView changePassword( public RedirectView changePassword(
Principal principal, Principal principal,
@RequestParam(name = "currentPassword") String currentPassword, @RequestParam(name = "currentPassword") String currentPassword,

View File

@ -16,11 +16,16 @@ import jakarta.servlet.http.HttpServletResponse;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import stirling.software.proprietary.audit.AuditEventType;
import stirling.software.proprietary.audit.AuditLevel;
import stirling.software.proprietary.audit.Audited;
@Slf4j @Slf4j
public class CustomOAuth2AuthenticationFailureHandler public class CustomOAuth2AuthenticationFailureHandler
extends SimpleUrlAuthenticationFailureHandler { extends SimpleUrlAuthenticationFailureHandler {
@Override @Override
@Audited(type = AuditEventType.USER_FAILED_LOGIN, level = AuditLevel.BASIC)
public void onAuthenticationFailure( public void onAuthenticationFailure(
HttpServletRequest request, HttpServletRequest request,
HttpServletResponse response, HttpServletResponse response,

View File

@ -24,6 +24,9 @@ import lombok.RequiredArgsConstructor;
import stirling.software.common.model.ApplicationProperties; import stirling.software.common.model.ApplicationProperties;
import stirling.software.common.model.exception.UnsupportedProviderException; import stirling.software.common.model.exception.UnsupportedProviderException;
import stirling.software.common.util.RequestUriUtils; import stirling.software.common.util.RequestUriUtils;
import stirling.software.proprietary.audit.AuditEventType;
import stirling.software.proprietary.audit.AuditLevel;
import stirling.software.proprietary.audit.Audited;
import stirling.software.proprietary.security.model.AuthenticationType; import stirling.software.proprietary.security.model.AuthenticationType;
import stirling.software.proprietary.security.service.JwtServiceInterface; import stirling.software.proprietary.security.service.JwtServiceInterface;
import stirling.software.proprietary.security.service.LoginAttemptService; import stirling.software.proprietary.security.service.LoginAttemptService;
@ -39,6 +42,7 @@ public class CustomOAuth2AuthenticationSuccessHandler
private final JwtServiceInterface jwtService; private final JwtServiceInterface jwtService;
@Override @Override
@Audited(type = AuditEventType.USER_LOGIN, level = AuditLevel.BASIC)
public void onAuthenticationSuccess( public void onAuthenticationSuccess(
HttpServletRequest request, HttpServletResponse response, Authentication authentication) HttpServletRequest request, HttpServletResponse response, Authentication authentication)
throws ServletException, IOException { throws ServletException, IOException {

View File

@ -14,11 +14,16 @@ import jakarta.servlet.http.HttpServletResponse;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import stirling.software.proprietary.audit.AuditEventType;
import stirling.software.proprietary.audit.AuditLevel;
import stirling.software.proprietary.audit.Audited;
@Slf4j @Slf4j
@ConditionalOnProperty(name = "security.saml2.enabled", havingValue = "true") @ConditionalOnProperty(name = "security.saml2.enabled", havingValue = "true")
public class CustomSaml2AuthenticationFailureHandler extends SimpleUrlAuthenticationFailureHandler { public class CustomSaml2AuthenticationFailureHandler extends SimpleUrlAuthenticationFailureHandler {
@Override @Override
@Audited(type = AuditEventType.USER_FAILED_LOGIN, level = AuditLevel.BASIC)
public void onAuthenticationFailure( public void onAuthenticationFailure(
HttpServletRequest request, HttpServletRequest request,
HttpServletResponse response, HttpServletResponse response,

View File

@ -23,6 +23,9 @@ import lombok.extern.slf4j.Slf4j;
import stirling.software.common.model.ApplicationProperties; import stirling.software.common.model.ApplicationProperties;
import stirling.software.common.model.exception.UnsupportedProviderException; import stirling.software.common.model.exception.UnsupportedProviderException;
import stirling.software.common.util.RequestUriUtils; import stirling.software.common.util.RequestUriUtils;
import stirling.software.proprietary.audit.AuditEventType;
import stirling.software.proprietary.audit.AuditLevel;
import stirling.software.proprietary.audit.Audited;
import stirling.software.proprietary.security.model.AuthenticationType; import stirling.software.proprietary.security.model.AuthenticationType;
import stirling.software.proprietary.security.service.JwtServiceInterface; import stirling.software.proprietary.security.service.JwtServiceInterface;
import stirling.software.proprietary.security.service.LoginAttemptService; import stirling.software.proprietary.security.service.LoginAttemptService;
@ -39,6 +42,7 @@ public class CustomSaml2AuthenticationSuccessHandler
private final JwtServiceInterface jwtService; private final JwtServiceInterface jwtService;
@Override @Override
@Audited(type = AuditEventType.USER_LOGIN, level = AuditLevel.BASIC)
public void onAuthenticationSuccess( public void onAuthenticationSuccess(
HttpServletRequest request, HttpServletResponse response, Authentication authentication) HttpServletRequest request, HttpServletResponse response, Authentication authentication)
throws ServletException, IOException { throws ServletException, IOException {

View File

@ -218,7 +218,7 @@ function loadAuditData(targetPage, realPageSize) {
showLoading('table-loading'); showLoading('table-loading');
// Always request page 0 from server, but with increased page size if needed // Always request page 0 from server, but with increased page size if needed
let url = `/audit/data?page=${requestedPage}&size=${realPageSize}`; let url = `/api/v1/audit/data?page=${requestedPage}&size=${realPageSize}`;
if (typeFilter) url += `&type=${encodeURIComponent(typeFilter)}`; if (typeFilter) url += `&type=${encodeURIComponent(typeFilter)}`;
if (principalFilter) url += `&principal=${encodeURIComponent(principalFilter)}`; if (principalFilter) url += `&principal=${encodeURIComponent(principalFilter)}`;
@ -302,7 +302,7 @@ function loadStats(days) {
showLoading('user-chart-loading'); showLoading('user-chart-loading');
showLoading('time-chart-loading'); showLoading('time-chart-loading');
fetchWithCsrf(`/audit/stats?days=${days}`) fetchWithCsrf(`/api/v1/audit/stats?days=${days}`)
.then(response => response.json()) .then(response => response.json())
.then(data => { .then(data => {
document.getElementById('total-events').textContent = data.totalEvents; document.getElementById('total-events').textContent = data.totalEvents;
@ -328,7 +328,7 @@ function exportAuditData(format) {
const startDate = exportStartDateFilter.value; const startDate = exportStartDateFilter.value;
const endDate = exportEndDateFilter.value; const endDate = exportEndDateFilter.value;
let url = format === 'json' ? '/audit/export/json?' : '/audit/export?'; let url = format === 'json' ? '/api/v1/audit/export/json?' : '/api/v1/audit/export/csv?';
if (type) url += `&type=${encodeURIComponent(type)}`; if (type) url += `&type=${encodeURIComponent(type)}`;
if (principal) url += `&principal=${encodeURIComponent(principal)}`; if (principal) url += `&principal=${encodeURIComponent(principal)}`;
@ -835,7 +835,7 @@ function hideLoading(id) {
// Load event types from the server for filter dropdowns // Load event types from the server for filter dropdowns
function loadEventTypes() { function loadEventTypes() {
fetchWithCsrf('/audit/types') fetchWithCsrf('/api/v1/audit/types')
.then(response => response.json()) .then(response => response.json())
.then(types => { .then(types => {
if (!types || types.length === 0) { if (!types || types.length === 0) {