Администратор может делать то, что обычному пользователю запрещено: отменить чужой заказ, сделать возврат, заблокировать аккаунт. Это необходимо — но без журнала таких действий невозможно понять, что произошло, если что-то пошло не так. Разберём, как организовать аудит администраторских команд в 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. Детали — вmetadataJSONB. - Два способа реализации:
@Around-аспект с аннотацией-маркером (DRY, меньше риск пропустить) или явная запись в Handler (больше контроля над метаданными). - Запись аудита — в той же транзакции и той же базе, что бизнес-операция. Иначе нет гарантии согласованности.
- Таблица append-only: только INSERT. Приложение лишается прав на UPDATE и DELETE.
- Хранить
request_idиtrace_id— для связки с трассировкой в системе наблюдаемости.
Что почитать дальше
- ABAC: владение ресурсом — когда admin override запускает обязательный аудит.
- PII и секреты — что нельзя хранить в
metadataоткрытым текстом.