16 KiB
Auditing with Spring Boot Actuator
Once Spring Security is in play, Spring Boot Actuator has a flexible audit framework that publishes events (by default, "authentication success", "failure" and "access denied" exceptions). This feature can be very useful for reporting and for implementing a lock-out policy based on authentication failures.
You can enable auditing by providing a bean of type AuditEventRepository in your application's configuration. For convenience, Spring Boot offers an InMemoryAuditEventRepository. InMemoryAuditEventRepository has limited capabilities, and we recommend using it only for development environments. For production environments, consider creating your own alternative AuditEventRepository implementation.
Basic Audit Configuration
In-Memory Audit Repository (Development)
@Configuration
public class AuditConfiguration {
@Bean
public AuditEventRepository auditEventRepository() {
return new InMemoryAuditEventRepository();
}
}
Database Audit Repository (Production)
@Entity
@Table(name = "audit_events")
public class PersistentAuditEvent {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(name = "principal", nullable = false)
private String principal;
@Column(name = "audit_event_type", nullable = false)
private String auditEventType;
@Column(name = "audit_event_date", nullable = false)
private Instant auditEventDate;
@ElementCollection
@MapKeyColumn(name = "name")
@Column(name = "value")
@CollectionTable(name = "audit_event_data",
joinColumns = @JoinColumn(name = "event_id"))
private Map<String, String> data = new HashMap<>();
// Constructors, getters, setters
}
@Repository
public class CustomAuditEventRepository implements AuditEventRepository {
private final PersistentAuditEventRepository repository;
public CustomAuditEventRepository(PersistentAuditEventRepository repository) {
this.repository = repository;
}
@Override
public void add(AuditEvent event) {
PersistentAuditEvent persistentEvent = new PersistentAuditEvent();
persistentEvent.setPrincipal(event.getPrincipal());
persistentEvent.setAuditEventType(event.getType());
persistentEvent.setAuditEventDate(event.getTimestamp());
persistentEvent.setData(event.getData());
repository.save(persistentEvent);
}
@Override
public List<AuditEvent> find(String principal, Instant after, String type) {
List<PersistentAuditEvent> events = repository.findByPrincipalAndAuditEventDateAfterAndAuditEventType(
principal, after, type);
return events.stream()
.map(this::convertToAuditEvent)
.collect(Collectors.toList());
}
private AuditEvent convertToAuditEvent(PersistentAuditEvent persistentEvent) {
return new AuditEvent(persistentEvent.getAuditEventDate(),
persistentEvent.getPrincipal(),
persistentEvent.getAuditEventType(),
persistentEvent.getData());
}
}
Custom Auditing
Custom Audit Events
You can publish custom audit events using AuditEventRepository:
@Service
public class UserService {
private final AuditEventRepository auditEventRepository;
private final UserRepository userRepository;
public UserService(AuditEventRepository auditEventRepository,
UserRepository userRepository) {
this.auditEventRepository = auditEventRepository;
this.userRepository = userRepository;
}
public User createUser(CreateUserRequest request) {
User user = userRepository.save(request.toUser());
// Publish audit event
Map<String, String> data = new HashMap<>();
data.put("userId", user.getId().toString());
data.put("username", user.getUsername());
data.put("email", user.getEmail());
AuditEvent event = new AuditEvent(getCurrentUsername(), "USER_CREATED", data);
auditEventRepository.add(event);
return user;
}
public void deleteUser(Long userId) {
User user = userRepository.findById(userId)
.orElseThrow(() -> new UserNotFoundException(userId));
userRepository.delete(user);
// Publish audit event
Map<String, String> data = new HashMap<>();
data.put("userId", userId.toString());
data.put("username", user.getUsername());
AuditEvent event = new AuditEvent(getCurrentUsername(), "USER_DELETED", data);
auditEventRepository.add(event);
}
private String getCurrentUsername() {
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
return auth != null ? auth.getName() : "system";
}
}
Custom Audit Event Publisher
@Component
public class AuditEventPublisher {
private final AuditEventRepository auditEventRepository;
public AuditEventPublisher(AuditEventRepository auditEventRepository) {
this.auditEventRepository = auditEventRepository;
}
public void publishEvent(String type, Map<String, String> data) {
String principal = getCurrentPrincipal();
AuditEvent event = new AuditEvent(principal, type, data);
auditEventRepository.add(event);
}
public void publishSecurityEvent(String type, String details) {
Map<String, String> data = new HashMap<>();
data.put("details", details);
data.put("timestamp", Instant.now().toString());
data.put("source", "security");
publishEvent(type, data);
}
public void publishBusinessEvent(String type, String entityId, String action) {
Map<String, String> data = new HashMap<>();
data.put("entityId", entityId);
data.put("action", action);
data.put("timestamp", Instant.now().toString());
publishEvent(type, data);
}
private String getCurrentPrincipal() {
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
return auth != null ? auth.getName() : "anonymous";
}
}
Method-Level Auditing
Using AOP for Automatic Auditing
@Target({ElementType.METHOD})
@Retention(RetentionPolicy.RUNTIME)
public @interface Auditable {
String value() default "";
String type() default "";
boolean includeArgs() default false;
boolean includeResult() default false;
}
@Aspect
@Component
public class AuditableAspect {
private final AuditEventPublisher auditEventPublisher;
public AuditableAspect(AuditEventPublisher auditEventPublisher) {
this.auditEventPublisher = auditEventPublisher;
}
@Around("@annotation(auditable)")
public Object auditMethod(ProceedingJoinPoint joinPoint, Auditable auditable) throws Throwable {
String methodName = joinPoint.getSignature().getName();
String className = joinPoint.getTarget().getClass().getSimpleName();
String auditType = auditable.type().isEmpty() ?
className + "." + methodName : auditable.type();
Map<String, String> data = new HashMap<>();
data.put("method", methodName);
data.put("class", className);
if (auditable.includeArgs()) {
Object[] args = joinPoint.getArgs();
for (int i = 0; i < args.length; i++) {
data.put("arg" + i, String.valueOf(args[i]));
}
}
try {
Object result = joinPoint.proceed();
if (auditable.includeResult() && result != null) {
data.put("result", String.valueOf(result));
}
data.put("status", "success");
auditEventPublisher.publishEvent(auditType, data);
return result;
} catch (Exception ex) {
data.put("status", "failure");
data.put("error", ex.getMessage());
auditEventPublisher.publishEvent(auditType, data);
throw ex;
}
}
}
Usage Example
@Service
public class OrderService {
@Auditable(type = "ORDER_CREATED", includeArgs = true)
public Order createOrder(CreateOrderRequest request) {
// Order creation logic
return new Order();
}
@Auditable(type = "ORDER_CANCELLED", includeResult = true)
public Order cancelOrder(Long orderId) {
// Order cancellation logic
return cancelledOrder;
}
@Auditable(type = "PAYMENT_PROCESSED")
public PaymentResult processPayment(PaymentRequest request) {
// Payment processing logic
return new PaymentResult();
}
}
Security Audit Events
Authentication Events
Spring Boot automatically publishes authentication events when using Spring Security:
AUTHENTICATION_SUCCESSAUTHENTICATION_FAILUREACCESS_DENIED
Custom Security Events
@Component
public class SecurityAuditService {
private final AuditEventPublisher auditEventPublisher;
public SecurityAuditService(AuditEventPublisher auditEventPublisher) {
this.auditEventPublisher = auditEventPublisher;
}
@EventListener
public void handleAuthenticationSuccess(AuthenticationSuccessEvent event) {
Map<String, String> data = new HashMap<>();
data.put("username", event.getAuthentication().getName());
data.put("authorities", event.getAuthentication().getAuthorities().toString());
data.put("source", getClientIP());
auditEventPublisher.publishEvent("AUTHENTICATION_SUCCESS", data);
}
@EventListener
public void handleAuthenticationFailure(AbstractAuthenticationFailureEvent event) {
Map<String, String> data = new HashMap<>();
data.put("username", event.getAuthentication().getName());
data.put("exception", event.getException().getClass().getSimpleName());
data.put("message", event.getException().getMessage());
data.put("source", getClientIP());
auditEventPublisher.publishEvent("AUTHENTICATION_FAILURE", data);
}
@EventListener
public void handleAccessDenied(AuthorizationDeniedEvent event) {
Map<String, String> data = new HashMap<>();
data.put("username", event.getAuthentication().getName());
data.put("resource", event.getAuthorizationDecision().toString());
data.put("source", getClientIP());
auditEventPublisher.publishEvent("ACCESS_DENIED", data);
}
private String getClientIP() {
RequestAttributes requestAttributes = RequestContextHolder.getRequestAttributes();
if (requestAttributes instanceof ServletRequestAttributes) {
HttpServletRequest request = ((ServletRequestAttributes) requestAttributes).getRequest();
return request.getRemoteAddr();
}
return "unknown";
}
}
Password Change Auditing
@Service
public class PasswordService {
private final AuditEventPublisher auditEventPublisher;
private final PasswordEncoder passwordEncoder;
public PasswordService(AuditEventPublisher auditEventPublisher,
PasswordEncoder passwordEncoder) {
this.auditEventPublisher = auditEventPublisher;
this.passwordEncoder = passwordEncoder;
}
public void changePassword(String oldPassword, String newPassword) {
String username = getCurrentUsername();
try {
// Validate old password
if (!isCurrentPassword(oldPassword)) {
Map<String, String> data = new HashMap<>();
data.put("username", username);
data.put("reason", "invalid_old_password");
auditEventPublisher.publishEvent("PASSWORD_CHANGE_FAILED", data);
throw new InvalidPasswordException("Invalid old password");
}
// Change password
updatePassword(newPassword);
// Audit success
Map<String, String> data = new HashMap<>();
data.put("username", username);
auditEventPublisher.publishEvent("PASSWORD_CHANGED", data);
} catch (Exception ex) {
Map<String, String> data = new HashMap<>();
data.put("username", username);
data.put("error", ex.getMessage());
auditEventPublisher.publishEvent("PASSWORD_CHANGE_ERROR", data);
throw ex;
}
}
private boolean isCurrentPassword(String password) {
// Implementation
return true;
}
private void updatePassword(String newPassword) {
// Implementation
}
private String getCurrentUsername() {
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
return auth != null ? auth.getName() : "anonymous";
}
}
Audit Events Endpoint
The /actuator/auditevents endpoint exposes audit events:
GET /actuator/auditevents
GET /actuator/auditevents?principal=user&after=2023-01-01T00:00:00Z&type=USER_CREATED
Response format:
{
"events": [
{
"timestamp": "2023-12-01T10:30:00Z",
"principal": "admin",
"type": "USER_CREATED",
"data": {
"userId": "123",
"username": "newuser",
"email": "user@example.com"
}
}
]
}
Production Configuration
Secure Audit Endpoint
@Configuration
public class AuditSecurityConfig {
@Bean
@Order(1)
public SecurityFilterChain auditSecurityFilterChain(HttpSecurity http) throws Exception {
return http
.requestMatcher(EndpointRequest.to("auditevents"))
.authorizeHttpRequests(requests ->
requests.anyRequest().hasRole("AUDITOR"))
.httpBasic(withDefaults())
.build();
}
}
Audit Configuration
management:
endpoint:
auditevents:
enabled: true
cache:
time-to-live: 10s
endpoints:
web:
exposure:
include: "auditevents"
# Custom audit properties
audit:
retention-days: 90
max-events-per-request: 100
sensitive-data-masking: true
Best Practices
- Data Sensitivity: Never include sensitive data (passwords, tokens) in audit events
- Performance: Consider async processing for high-volume audit events
- Retention: Implement audit data retention policies
- Security: Secure the audit endpoint and audit data storage
- Monitoring: Monitor audit system health and performance
- Compliance: Ensure audit events meet regulatory requirements
- Immutability: Ensure audit events cannot be modified after creation
Async Audit Processing
@Configuration
@EnableAsync
public class AsyncAuditConfiguration {
@Bean
public TaskExecutor auditTaskExecutor() {
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
executor.setCorePoolSize(2);
executor.setMaxPoolSize(5);
executor.setQueueCapacity(100);
executor.setThreadNamePrefix("audit-");
executor.initialize();
return executor;
}
}
@Service
public class AsyncAuditEventRepository implements AuditEventRepository {
private final AuditEventRepository delegate;
public AsyncAuditEventRepository(AuditEventRepository delegate) {
this.delegate = delegate;
}
@Override
@Async("auditTaskExecutor")
public void add(AuditEvent event) {
delegate.add(event);
}
@Override
public List<AuditEvent> find(String principal, Instant after, String type) {
return delegate.find(principal, after, type);
}
}