Alice finds that her private draft is now public, but says she did not publish it. The application recorded:
Document published successfully
We need more: the authenticated account, document, request, and whether the change committed.
This simulated document application builds on Authorization Beyond Roles in Spring Boot, using Java 21, Spring Boot 3.5, Spring Security, and Spring Data JPA.
Record the actor and the outcome
For publication events, use a consistent set of fields:
| Field | Purpose |
|---|---|
@timestamp |
Record emission time, including the zone offset |
service_name |
Application that emitted the record |
event_id |
Unique event identifier |
request_id |
Correlate events within one HTTP request |
actor_id |
Authenticated account |
document_id |
Requested resource |
document_version |
Row version involved in publication |
event and outcome |
Operation and observed result |
reason |
Stable application value explaining a refusal |
Keep these meanings consistent across services. Authentication, authorization, and publication are distinct events. A request ID connects records; it does not establish identity. OWASP logging vocabulary
Keep every operation aware of the user context
Every operation needs an explicit identity and access policy, including reads, lists, searches, exports, updates, deletes, publication, and background work.
Obtain the caller from Spring Security, constrain the operation by their permissions, and carry that identity into its audit event. An owner or actor_id supplied in JSON cannot identify the caller.
Enforce this in application services. Scope list and export queries before pagination, and authorize writes before changing data. In a system with multiple organizations, include the verified organization scope. Adding a username to a log does not secure an unrestricted query. OWASP authorization guidance
For queued work, retain the initiating account and resource scope in server-controlled metadata, then recheck permissions at execution. Distinguish that user from the service executing the job. System maintenance needs its own limited service identity; public operations need an explicit anonymous policy.
Write structured events
Enable Spring Boot's Logstash JSON output:
spring.application.name=security-logs-demo
logging.structured.format.console=logstash
logging.structured.json.add.service_name=security-logs-demo
The formatter includes timestamps, levels, MDC values, and SLF4J key-value fields. MDC is the mapped diagnostic context used to attach metadata to log records. Spring Boot structured logging
A SecurityAudit component records ownership refusals through its SLF4J logger:
public void denied(String actorId, UUID documentId) {
log.atInfo()
.addKeyValue("event_id", UUID.randomUUID().toString())
.addKeyValue("event", "document.publish")
.addKeyValue("outcome", "denied")
.addKeyValue("reason", "not_accessible")
.addKeyValue("actor_id", actorId)
.addKeyValue("document_id", documentId.toString())
.log("Document publication");
}
The fluent API keeps fields separate from the constant message. denied describes an observation: a refusal alone does not prove an attack. SLF4J fluent logging
Correlate requests
Generate a request ID on the server, add it to MDC, and return it in the response:
@Component
@Order(Ordered.HIGHEST_PRECEDENCE)
public class RequestIdFilter extends OncePerRequestFilter {
@Override
protected void doFilterInternal(
HttpServletRequest request,
HttpServletResponse response,
FilterChain chain
) throws ServletException, IOException {
String previous = MDC.get("request_id");
String requestId = UUID.randomUUID().toString();
MDC.put("request_id", requestId);
response.setHeader("X-Request-ID", requestId);
try {
chain.doFilter(request, response);
} finally {
if (previous == null) {
MDC.remove("request_id");
} else {
MDC.put("request_id", previous);
}
}
}
}
The filter ignores incoming X-Request-ID values and restores the previous MDC value because servlet threads are reused. Users can quote the response ID when reporting a problem.
This handles synchronous requests. Background work needs explicit propagation of correlation metadata and the correct authorization context; copying a request ID does not establish permissions.
Record publication after commit
Logging success immediately after document.publish() is premature: the transaction can still roll back. Instead, publish an application event inside the transaction:
@Transactional
@PreAuthorize("hasRole('EDITOR')")
public void publish(UUID id) {
String actorId = currentAccount.username();
Document document = documents
.findByIdAndOwnerUsername(id, actorId)
.orElseThrow(() -> {
audit.denied(actorId, id);
return new ResponseStatusException(HttpStatus.NOT_FOUND);
});
if (document.isPublished()) {
return;
}
document.publish();
documents.flush();
events.publishEvent(new DocumentPublication(
UUID.randomUUID(),
actorId,
id,
document.getVersion(),
MDC.get("request_id")
));
}
currentAccount.username() reads the authenticated name. Usernames are fixed account keys in this example; use an immutable account ID where usernames can change. @PreAuthorize requires @EnableMethodSecurity and invocation through the Spring proxy. Spring Security method security
Document has a JPA @Version field. Flushing exposes the updated row version, but flush is not commit. Capture the operation's context in the event:
public record DocumentPublication(
UUID eventId,
String actorId,
UUID documentId,
long documentVersion,
String requestId
) {
}
The listener records success only after commit:
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
public void committed(DocumentPublication event) {
String previous = MDC.get("request_id");
if (event.requestId() == null) {
MDC.remove("request_id");
} else {
MDC.put("request_id", event.requestId());
}
try {
log.atInfo()
.addKeyValue("event_id", event.eventId().toString())
.addKeyValue("event", "document.publish")
.addKeyValue("outcome", "committed")
.addKeyValue("actor_id", event.actorId())
.addKeyValue("document_id", event.documentId().toString())
.addKeyValue("document_version", event.documentVersion())
.log("Document publication");
} finally {
if (previous == null) {
MDC.remove("request_id");
} else {
MDC.put("request_id", previous);
}
}
}
A rollback produces no committed event. Without an active transaction, this listener does not run by default. The direct denial log remains valid even when its surrounding transaction rolls back. Spring transaction events
An already published document creates no second transition event. Authentication, role, and CSRF refusals happen before this service body and need separate security-handler or identity-provider events.
Reconstruct the incident
A simplified publication record looks like this:
{
"@timestamp": "2026-10-25T08:14:07Z",
"service_name": "security-logs-demo",
"event_id": "5f468d12-97c0-4ec8-a2a0-dc0488d58cc3",
"request_id": "9e0b4781-c1f4-45d9-967e-59aa00d24118",
"actor_id": "alice",
"document_id": "5f55b830-a150-4edf-88ab-c16ab6ddcc54",
"document_version": 4,
"event": "document.publish",
"outcome": "committed"
}
Our simulated timeline, with shortened request labels, is:
| Time in UTC | Account | Resource | Outcome | Request |
|---|---|---|---|---|
| 08:14:02 | Alice | Bob's document | Denied | A |
| 08:14:07 | Alice | Alice's document, version 4 | Committed | B |
These records associate two requests with Alice's authenticated account. They do not prove Alice personally made them or that one caused the other. Correlate them with authentication, session lifecycle, and deployment records.
Synchronize clocks and normalize timestamps. Recovering the document's exact text requires revision history; the row version alone does not preserve it.
Protect the evidence
Select fields explicitly. Exclude document content, passwords, authorization headers, and session cookies. Restrict access to account identifiers and define retention. Forward records to protected storage, monitor delivery failures and capacity, and limit deletion permissions. OWASP logging guidance
Keep event names and reasons under application control. Use an encoder that escapes quotes and line breaks, bound input-derived values, and preserve JSON framing throughout collection. Parameter placeholders alone do not prevent plain-text log injection. CWE-117
AFTER_COMMIT does not guarantee durable delivery: the process can stop before the log reaches storage. Where reliable recording is required, write an outbox event in the same database transaction, deliver it with retries, and handle duplicates using its stable event ID. Transactional outbox pattern
