Compare commits

..
Author SHA1 Message Date
Anthony Stirling c99674f430 audit stuff 2026-01-04 23:47:49 +00:00
11 changed files with 404 additions and 108 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
@@ -1,7 +1,7 @@
package stirling.software.SPDF.controller.api;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.io.OutputStream;
import java.nio.file.Files;
import java.util.*;
import java.util.zip.ZipEntry;
@@ -22,6 +22,7 @@ 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.multipart.MultipartFile;
import org.springframework.web.servlet.mvc.method.annotation.StreamingResponseBody;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
@@ -33,6 +34,7 @@ import stirling.software.SPDF.model.api.SplitPdfBySectionsRequest;
import stirling.software.common.service.CustomPDFDocumentFactory;
import stirling.software.common.util.ExceptionUtils;
import stirling.software.common.util.GeneralUtils;
import stirling.software.common.util.PDFService;
import stirling.software.common.util.TempFile;
import stirling.software.common.util.TempFileManager;
import stirling.software.common.util.WebResponseUtils;
@@ -45,6 +47,7 @@ public class SplitPdfBySectionsController {
private final CustomPDFDocumentFactory pdfDocumentFactory;
private final TempFileManager tempFileManager;
private final PDFService pdfService;
@PostMapping(value = "/split-pdf-by-sections", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
@Operation(
@@ -54,8 +57,8 @@ public class SplitPdfBySectionsController {
+ " which page to split, and how to split"
+ " ( halves, thirds, quarters, etc.), both vertically and horizontally."
+ " Input:PDF Output:ZIP-PDF Type:SISO")
public ResponseEntity<?> splitPdf(@ModelAttribute SplitPdfBySectionsRequest request)
throws Exception {
public ResponseEntity<StreamingResponseBody> splitPdf(
@ModelAttribute SplitPdfBySectionsRequest request) throws Exception {
MultipartFile file = request.getFileInput();
String pageNumbers = request.getPageNumbers();
SplitTypes splitMode =
@@ -63,10 +66,9 @@ public class SplitPdfBySectionsController {
.map(SplitTypes::valueOf)
.orElse(SplitTypes.SPLIT_ALL);
try (PDDocument sourceDocument = pdfDocumentFactory.load(file, true)) {
int totalPages = sourceDocument.getNumberOfPages();
Set<Integer> customPagesToSplit =
getCustomPagesToSplit(pageNumbers, splitMode, totalPages);
try (PDDocument sourceDocument = pdfDocumentFactory.load(file)) {
Set<Integer> pagesToSplit =
getPagesToSplit(pageNumbers, splitMode, sourceDocument.getNumberOfPages());
// Process the PDF based on split parameters
int horiz = request.getHorizontalDivisions() + 1;
@@ -75,13 +77,14 @@ public class SplitPdfBySectionsController {
String filename = GeneralUtils.generateFilename(file.getOriginalFilename(), "_split");
if (merge) {
try (PDDocument mergedDoc =
pdfDocumentFactory.createNewDocumentBasedOnOldDocument(
sourceDocument);
ByteArrayOutputStream baos = new ByteArrayOutputStream()) {
TempFile tempFile = new TempFile(tempFileManager, ".pdf");
try (PDDocument mergedDoc = pdfDocumentFactory.createNewDocument();
OutputStream out = Files.newOutputStream(tempFile.getPath())) {
LayerUtility layerUtility = new LayerUtility(mergedDoc);
for (int pageIndex = 0; pageIndex < totalPages; pageIndex++) {
if (shouldSplitPage(pageIndex, totalPages, splitMode, customPagesToSplit)) {
for (int pageIndex = 0;
pageIndex < sourceDocument.getNumberOfPages();
pageIndex++) {
if (pagesToSplit.contains(pageIndex)) {
addSplitPageToTarget(
sourceDocument,
pageIndex,
@@ -93,16 +96,18 @@ public class SplitPdfBySectionsController {
addPageToTarget(sourceDocument, pageIndex, mergedDoc, layerUtility);
}
}
mergedDoc.save(baos);
return WebResponseUtils.baosToWebResponse(baos, filename + ".pdf");
mergedDoc.save(out);
}
return WebResponseUtils.pdfFileToWebResponse(tempFile, filename + ".pdf");
} else {
TempFile zipTempFile = new TempFile(tempFileManager, ".zip");
try (ZipOutputStream zipOut =
new ZipOutputStream(Files.newOutputStream(zipTempFile.getPath()))) {
for (int pageIndex = 0; pageIndex < totalPages; pageIndex++) {
for (int pageIndex = 0;
pageIndex < sourceDocument.getNumberOfPages();
pageIndex++) {
int pageNum = pageIndex + 1;
if (shouldSplitPage(pageIndex, totalPages, splitMode, customPagesToSplit)) {
if (pagesToSplit.contains(pageIndex)) {
for (int i = 0; i < horiz; i++) {
for (int j = 0; j < verti; j++) {
try (PDDocument subDoc =
@@ -240,37 +245,53 @@ public class SplitPdfBySectionsController {
}
// Based on the mode, get the pages that need to be split and return the pages set
private Set<Integer> getCustomPagesToSplit(
String pageNumbers, SplitTypes splitMode, int totalPages) {
if (splitMode != SplitTypes.CUSTOM) {
return Collections.emptySet();
}
if (pageNumbers == null || pageNumbers.isBlank()) {
throw ExceptionUtils.createIllegalArgumentException(
"error.argumentRequired",
"{0} is required for {1} mode",
"page numbers",
"custom");
}
String[] pageOrderArr = pageNumbers.split(",");
List<Integer> pageListToSplit = GeneralUtils.parsePageList(pageOrderArr, totalPages, false);
return new HashSet<>(pageListToSplit);
}
private Set<Integer> getPagesToSplit(String pageNumbers, SplitTypes splitMode, int totalPages) {
Set<Integer> pagesToSplit = new HashSet<>();
private boolean shouldSplitPage(
int pageIndex, int totalPages, SplitTypes splitMode, Set<Integer> customPagesToSplit) {
return switch (splitMode) {
case CUSTOM -> customPagesToSplit.contains(pageIndex);
case SPLIT_ALL -> true;
case SPLIT_ALL_EXCEPT_FIRST -> pageIndex > 0;
case SPLIT_ALL_EXCEPT_LAST -> pageIndex < totalPages - 1;
case SPLIT_ALL_EXCEPT_FIRST_AND_LAST -> pageIndex > 0 && pageIndex < totalPages - 1;
default ->
switch (splitMode) {
case CUSTOM:
if (pageNumbers == null || pageNumbers.isBlank()) {
throw ExceptionUtils.createIllegalArgumentException(
"error.invalidFormat",
"Invalid {0} format: {1}",
"split mode",
splitMode);
};
"error.argumentRequired",
"{0} is required for {1} mode",
"page numbers",
"custom");
}
String[] pageOrderArr = pageNumbers.split(",");
List<Integer> pageListToSplit =
GeneralUtils.parsePageList(pageOrderArr, totalPages, false);
pagesToSplit.addAll(pageListToSplit);
break;
case SPLIT_ALL:
for (int i = 0; i < totalPages; i++) {
pagesToSplit.add(i);
}
break;
case SPLIT_ALL_EXCEPT_FIRST:
for (int i = 1; i < totalPages; i++) {
pagesToSplit.add(i);
}
break;
case SPLIT_ALL_EXCEPT_LAST:
for (int i = 0; i < totalPages - 1; i++) {
pagesToSplit.add(i);
}
break;
case SPLIT_ALL_EXCEPT_FIRST_AND_LAST:
for (int i = 1; i < totalPages - 1; i++) {
pagesToSplit.add(i);
}
break;
default:
throw ExceptionUtils.createIllegalArgumentException(
"error.invalidFormat", "Invalid {0} format: {1}", "split mode", splitMode);
}
return pagesToSplit;
}
}
@@ -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";
}
}
}
@@ -15,7 +15,6 @@ import re
from PIL import Image, ImageDraw
API_HEADERS = {"X-API-KEY": "123456789"}
TIMEOUT = 80 # seconds
#########
# GIVEN #
@@ -299,11 +298,7 @@ def save_generated_pdf(context, filename):
def step_send_get_request(context, endpoint):
base_url = "http://localhost:8080"
full_url = f"{base_url}{endpoint}"
response = requests.get(
full_url,
headers=API_HEADERS,
timeout=getattr(context, "request_timeout", TIMEOUT),
)
response = requests.get(full_url, headers=API_HEADERS)
context.response = response
@@ -312,12 +307,7 @@ def step_send_get_request_with_params(context, endpoint):
base_url = "http://localhost:8080"
params = {row["parameter"]: row["value"] for row in context.table}
full_url = f"{base_url}{endpoint}"
response = requests.get(
full_url,
params=params,
headers=API_HEADERS,
timeout=getattr(context, "request_timeout", TIMEOUT),
)
response = requests.get(full_url, params=params, headers=API_HEADERS)
context.response = response
@@ -347,12 +337,7 @@ def step_send_api_request(context, endpoint):
print(f"form_data {file.name} with {mime_type}")
form_data.append((key, (file.name, file, mime_type)))
response = requests.post(
url,
files=form_data,
headers=API_HEADERS,
timeout=getattr(context, "request_timeout", TIMEOUT),
)
response = requests.post(url, files=form_data, headers=API_HEADERS)
context.response = response