← назад к разделу

Администратор может делать то, что обычному пользователю запрещено: отменить чужой заказ, сделать возврат, заблокировать аккаунт. Это необходимо — но без журнала таких действий невозможно понять, что произошло, если что-то пошло не так. Разберём, как организовать аудит администраторских команд в Spring-приложении.

Зачем нужен журнал действий

Представьте: поступает жалоба, что заказ клиента отменён без его ведома. Вопросы сразу:

  • Кто именно из администраторов это сделал?
  • Когда именно?
  • Что было причиной?

Без журнала ответить невозможно. Ещё хуже — если учётная запись администратора была скомпрометирована, атакующий действовал незаметно.

Аудит решает три задачи: следствие по инцидентам, проверка соответствия требованиям (compliance), и раннее обнаружение подозрительного поведения.

Правило простое: каждое действие администратора, меняющее состояние данных, должно оставлять запись в журнале.

Структура таблицы audit log

Для журнала достаточно одной таблицы на весь сервис. Если агрегатов несколько и их схемы сильно отличаются — можно делать отдельную таблицу на агрегат (order_audit_log, payment_audit_log), но для большинства случаев хватает одной.

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);

Что за что отвечает:

  • actor_id — кто выполнил действие (обязательно).
  • occurred_at — когда (обязательно).
  • action — что именно: "cancel-order", "block-user", "issue-refund".
  • resource_type + resource_id — к чему применялось: Order / 42, User / 7.
  • metadata — расширяемое JSONB-поле для деталей: предыдущий и новый статус, причина, связанные идентификаторы. Схему не фиксируем заранее — добавляем по необходимости.
  • request_id и trace_id — для связки записи с конкретным HTTP-запросом и трассировкой в системе наблюдаемости.

Способ первый: Spring AOP аспект

Если администраторских действий много и они разбросаны по разным Handler-ам, удобно использовать аспект: помечаем методы аннотацией, аспект записывает журнал автоматически.

Сначала объявляем аннотацию-маркер:

@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface AdminAction {
    String action();
    String resourceType();
    String resourceIdParam() default "id";
}

Затем сам аспект:

@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;
    }
}

И использование в 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);
    }
}

Плюс подхода: аннотацию сложно забыть поставить, запись в журнал происходит автоматически. Минус: если для конкретного действия нужна детальная информация о состоянии объекта до и после — аспект не всегда получит её без дополнительной работы.

Способ второй: явная запись в Handler

Когда метаданные нужны нестандартные — например, важно зафиксировать статус заказа до изменения — удобнее записать аудит прямо внутри 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;
    }
}

Плюс: весь смысл операции виден в одном месте. Минус: запись аудита можно случайно пропустить при добавлении нового Handler-а.

Итого: аспект — для типовых случаев, явный вызов — когда нужен специфический контекст.

Одна транзакция — обязательное условие

Это самая важная часть. Запись аудита должна происходить в той же базе данных и в той же транзакции, что и бизнес-операция.

@Transactional начало
  order.cancel()
  orderRepository.save(order)
  auditLogRepository.append(...)   ← здесь же
@Transactional commit

Почему это важно:

  • Если бизнес-операция откатилась — запись аудита тоже откатится. Не будет ложных записей о несостоявшихся действиях.
  • Если операция успешно завершилась — запись аудита гарантированно есть. Пропустить настоящее действие невозможно.

Часто возникает идея отправлять аудит асинхронно — через очередь или отдельный сервис. Проблема: между фиксацией транзакции в основной базе и публикацией события есть момент, когда приложение может упасть. Действие состоялось, запись в журнале — нет.

Если команда по безопасности требует централизованный журнал — правильное решение: пишем локально (в той же транзакции) и дополнительно публикуем через механизм гарантированной доставки уже после фиксации транзакции.

Append-only: удалять нельзя

Таблица аудита — только для добавления. Ни администраторы приложения, ни само приложение не должны иметь возможности изменить или удалить уже записанные строки:

REVOKE UPDATE, DELETE ON admin_audit_log FROM application_role;

Если запись об администраторском действии можно удалить — смысл аудита теряется. Compliance-требования большинства отраслей предполагают хранение журнала от одного до семи лет.

Очистка старых записей — отдельная задача с привилегиями уровня DBA, не из кода приложения.

Частые ошибки

Аудит написали, но без actor_id или occurred_at. Это делает журнал бесполезным — непонятно кто и когда.

Запись аудита вынесли за пределы транзакции — например, в @TransactionalEventListener с AFTER_COMMIT. Операция зафиксирована, но до записи в журнал приложение упало. Пропуск.

Email администратора хранится открытым текстом в поле actor_email. Если журнал когда-нибудь утечёт — это лишние персональные данные. Лучше хранить только actor_id или хэш email.

Аудит только для «опасных» операций, а не для всех admin-действий. Что считается опасным — всегда субъективно. Правило проще: любое изменение данных от роли admin пишем в журнал.

Коротко

  • Каждое действие администратора, изменяющее данные, должно оставлять запись в журнале.
  • Минимальный набор полей: actor_id, occurred_at, action, resource_type, resource_id. Детали — в metadata JSONB.
  • Два способа реализации: @Around-аспект с аннотацией-маркером (DRY, меньше риск пропустить) или явная запись в Handler (больше контроля над метаданными).
  • Запись аудита — в той же транзакции и той же базе, что бизнес-операция. Иначе нет гарантии согласованности.
  • Таблица append-only: только INSERT. Приложение лишается прав на UPDATE и DELETE.
  • Хранить request_id и trace_id — для связки с трассировкой в системе наблюдаемости.

Что почитать дальше