← Back to the section

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_id and trace_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 into metadata JSONB.
  • Two ways to implement it: an @Around aspect 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_id and trace_id — to link the record with tracing in the observability system.