An administrator can do things an ordinary user is not allowed to: cancel someone else's order, issue a refund, block an account. This is necessary — but without a log of such actions it is impossible to understand what happened if something goes wrong. Let's look at how to organize the audit of administrator commands in a Spring application.
Why you need an action log
Imagine: a complaint comes in that a customer's order was cancelled without their knowledge. The questions come immediately:
- Which administrator exactly did this?
- When exactly?
- What was the reason?
Without a log you cannot answer. Even worse — if the administrator's account was compromised, the attacker acted unnoticed.
The audit solves three tasks: incident investigation, compliance verification, and early detection of suspicious behavior.
The rule is simple: every administrator action that changes the state of data must leave a record in the log.
Structure of the audit log table
A single table for the whole service is enough for the log. If there are several aggregates and their schemas differ significantly — you can create a separate table per aggregate (order_audit_log, payment_audit_log), but for most cases one is enough.
CREATE TABLE admin_audit_log (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
actor_id text NOT NULL,
actor_email text,
action text NOT NULL,
resource_type text NOT NULL,
resource_id text NOT NULL,
occurred_at timestamptz NOT NULL DEFAULT now(),
metadata jsonb NOT NULL DEFAULT '{}',
request_id text,
trace_id text
);
CREATE INDEX ix_admin_audit_actor ON admin_audit_log(actor_id, occurred_at DESC);
CREATE INDEX ix_admin_audit_resource ON admin_audit_log(resource_type, resource_id);
What each field is responsible for:
actor_id— who performed the action (required).occurred_at— when (required).action— what exactly:"cancel-order","block-user","issue-refund".resource_type+resource_id— what it was applied to:Order / 42,User / 7.metadata— an extensible JSONB field for details: previous and new status, reason, related identifiers. We don't fix the schema in advance — we add things as needed.request_idandtrace_id— to link the record to a specific HTTP request and to tracing in the observability system.
Approach one: Spring AOP aspect
If there are many administrator actions and they are scattered across different Handlers, it is convenient to use an aspect: we mark the methods with an annotation, and the aspect writes the log automatically.
First we declare a marker annotation:
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface AdminAction {
String action();
String resourceType();
String resourceIdParam() default "id";
}
Then the aspect itself:
@Aspect
@Component
@RequiredArgsConstructor
public class AdminAuditAspect {
private final AdminAuditLogRepository auditLogRepository;
private final AuthenticatedUserProvider userProvider;
@Around("@annotation(adminAction)")
public Object audit(ProceedingJoinPoint joinPoint, AdminAction adminAction) throws Throwable {
var user = userProvider.current();
if (!user.isAdmin()) {
return joinPoint.proceed();
}
var result = joinPoint.proceed();
var resourceId = extractResourceId(joinPoint, adminAction);
auditLogRepository.append(AdminAuditRecord.builder()
.actorId(user.id().toString())
.actorEmail(user.email())
.action(adminAction.action())
.resourceType(adminAction.resourceType())
.resourceId(resourceId)
.occurredAt(Instant.now())
.metadata(buildMetadata(joinPoint, result))
.requestId(MDC.get("requestId"))
.traceId(MDC.get("traceId"))
.build());
return result;
}
}
And its usage in a Handler:
@UseCase
@RequiredArgsConstructor
public class CancelOrderHandler implements UseCaseHandler<CancelOrderCommand, Order> {
@Override
@Transactional
@AdminAction(action = "cancel-order", resourceType = "Order", resourceIdParam = "orderId")
public Order handle(CancelOrderCommand command) {
var order = orderRepository.findById(command.orderId()).orElseThrow();
order.cancel();
return orderRepository.save(order);
}
}
Upside of the approach: the annotation is hard to forget, and the record is written to the log automatically. Downside: if a specific action needs detailed information about the object's state before and after — the aspect won't always get it without extra work.
Approach two: explicit write in the Handler
When the metadata is non-standard — for example, it matters to capture the order status before the change — it is more convenient to write the audit right inside the Handler:
@UseCase
@RequiredArgsConstructor
public class CancelOrderHandler implements UseCaseHandler<CancelOrderCommand, Order> {
private final OrderRepository orderRepository;
private final AuthenticatedUserProvider userProvider;
private final AdminAuditLogRepository auditLogRepository;
@Override
@Transactional
public Order handle(CancelOrderCommand command) {
var order = orderRepository.findById(command.orderId()).orElseThrow();
var user = userProvider.current();
if (!user.isAdmin() && !order.getCustomerId().equals(user.id())) {
throw new ForbiddenException();
}
var previousStatus = order.status();
order.cancel();
var saved = orderRepository.save(order);
if (user.isAdmin()) {
auditLogRepository.append(AdminAuditRecord.builder()
.actorId(user.id().toString())
.action("cancel-order")
.resourceType("Order")
.resourceId(order.id().toString())
.occurredAt(Instant.now())
.metadata(Map.of(
"previousStatus", previousStatus.name(),
"newStatus", order.status().name(),
"ownerCustomerId", order.getCustomerId()
))
.build());
}
return saved;
}
}
Upside: the whole meaning of the operation is visible in one place. Downside: it is easy to accidentally skip the audit write when adding a new Handler.
Bottom line: the aspect is for typical cases, the explicit call is when you need specific context.
A single transaction is a mandatory condition
This is the most important part. The audit write must happen in the same database and in the same transaction as the business operation.
@Transactional start
order.cancel()
orderRepository.save(order)
auditLogRepository.append(...) ← right here
@Transactional commit
Why this matters:
- If the business operation rolled back — the audit record rolls back too. There will be no false records of actions that never happened.
- If the operation completed successfully — the audit record is guaranteed to exist. It is impossible to miss a real action.
A common idea is to send the audit asynchronously — through a queue or a separate service. The problem: between committing the transaction in the main database and publishing the event there is a moment when the application can crash. The action happened, the log record did not.
If the security team requires a centralized log — the correct solution is: write locally (in the same transaction) and additionally publish through a guaranteed-delivery mechanism after the transaction is committed.
Append-only: no deletions allowed
The audit table is insert-only. Neither the application's administrators nor the application itself should be able to modify or delete rows that have already been written:
REVOKE UPDATE, DELETE ON admin_audit_log FROM application_role;
If a record of an administrator action can be deleted — the point of the audit is lost. Compliance requirements in most industries assume the log is retained for one to seven years.
Purging old records is a separate task with DBA-level privileges, not something done from the application code.
Common mistakes
The audit was written, but without actor_id or occurred_at. This makes the log useless — it is unclear who and when.
The audit write was moved outside the transaction — for example, into a @TransactionalEventListener with AFTER_COMMIT. The operation is committed, but before the log record was written the application crashed. A miss.
The administrator's email is stored in plain text in the actor_email field. If the log ever leaks — this is extra personal data. It is better to store only actor_id or a hash of the email.
The audit only covers "dangerous" operations, not all admin actions. What counts as dangerous is always subjective. The rule is simpler: any data change made under the admin role goes into the log.
In short
- Every administrator action that changes data must leave a record in the log.
- The minimal set of fields:
actor_id,occurred_at,action,resource_type,resource_id. Details go intometadataJSONB. - Two ways to implement it: an
@Aroundaspect with a marker annotation (DRY, less risk of missing it) or an explicit write in the Handler (more control over metadata). - The audit write goes into the same transaction and the same database as the business operation. Otherwise there is no consistency guarantee.
- The table is append-only: INSERT only. The application is stripped of UPDATE and DELETE rights.
- Store
request_idandtrace_id— to link the record with tracing in the observability system.
What to read next
- ABAC: resource ownership — when an admin override triggers a mandatory audit.
- PII and secrets — what must not be stored in
metadatain plain text.