Администратор может делать то, что обычному пользователю запрещено: отменить чужой заказ, сделать возврат, заблокировать аккаунт. Это необходимо — но без журнала таких действий невозможно понять, что произошло, если что-то пошло не так. Разберём, как организовать аудит администраторских команд в Spring-приложении.
Главный вопрос здесь не «писать ли журнал», а где именно стоит строка записи — до фиксации транзакции или после неё.
Дорожки расходятся ровно в одном такте — когда приложение падает сразу после COMMIT: у дорожки A запись уже внутри той же транзакции (1 действие — 1 запись), у дорожки B действие есть, а записи нет.
Зачем нужен журнал действий
Представьте: поступает жалоба, что заказ клиента отменён без его ведома. Вопросы сразу:
- Кто именно из администраторов это сделал?
- Когда именно?
- Что было причиной?
Без журнала ответить невозможно. Ещё хуже — если учётная запись администратора была скомпрометирована, атакующий действовал незаметно.
Аудит решает три задачи: следствие по инцидентам, проверка соответствия требованиям (compliance), и раннее обнаружение подозрительного поведения.
Правило простое: каждое действие администратора, меняющее состояние данных, должно оставлять запись в журнале.
Структура таблицы audit log
Таблица должна отвечать на те же вопросы, что и жалоба из начала статьи: кто сделал, когда, что именно и с каким объектом. К ним добавляются два служебных: как связать запись с конкретным запросом и куда класть детали, не переписывая схему под каждое новое действие. Получается такой набор колонок:
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-запросом и трассировкой в системе наблюдаемости.
Для журнала достаточно одной таблицы на весь сервис. Если агрегатов несколько и их схемы сильно отличаются — можно делать отдельную таблицу на агрегат (order_audit_log, payment_audit_log), но для большинства случаев хватает одной.
Откуда берётся каждое обязательное поле записи и куда она попадает: строка собирается из четырёх источников и уходит в таблицу тем же коммитом, что и сама операция.
CREATE TABLE admin_audit_log (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
actor_id text NOT NULL,
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);
Что положить в metadata, чтобы запись была полезна через год
metadata — то место, где журнал либо становится инструментом, либо остаётся формальностью. Через год к записи придут с вопросом «почему это сделали», и {"status": "CANCELLED"} на него не отвечает.
Что стоит класть всегда:
- Состояние до и после.
{"from": "PAID", "to": "CANCELLED"}. Без «до» непонятно, что именно изменилось, а восстановить прежнее значение из данных уже нельзя — его перезаписали. - Причина. Текст, который сотрудник ввёл в форме, обязательное поле команды, а не необязательный комментарий. Пустая строка не проходит валидацию.
- Номер обращения. Ссылка на жалобу или заявку, из которой выросло действие. Это то поле, которое через год отличает работу по обращению от любопытства.
- От чьего имени. Признак того, что администратор действовал по просьбе клиента, и идентификатор этого клиента. Без него две одинаковые записи — «по просьбе владельца» и «по своей инициативе» — выглядят одинаково.
- Канал. Из административного интерфейса, из скрипта, в составе массовой операции. Массовая операция на тысячу объектов должна быть узнаваема как одна, а не как тысяча независимых действий.
Чего в metadata быть не должно — персональных данных открытым текстом. Журнал живёт годами и переживёт удаление клиента, а значит превратится в копию его данных, которую придётся удалять по заявлению. Внутри кладут идентификаторы и коды; подробнее — в статье про персональные данные. И не весь объект целиком «на всякий случай»: снимки при каждом изменении превращают журнал во вторую базу.
Одна транзакция — обязательное условие
Это самая важная часть. Запись аудита должна происходить в той же базе данных и в той же транзакции, что и бизнес-операция.
@Transactional начало
order.cancel()
orderRepository.save(order)
auditLogRepository.append(...) ← здесь же
@Transactional commit
Почему это важно:
- Если бизнес-операция откатилась — запись аудита тоже откатится. Не будет ложных записей о несостоявшихся действиях.
- Если операция успешно завершилась — запись аудита гарантированно есть. Пропустить настоящее действие невозможно.
Часто возникает идея отправлять аудит асинхронно — через очередь или отдельный сервис. Проблема: между фиксацией транзакции в основной базе и публикацией события есть момент, когда приложение может упасть. Действие состоялось, запись в журнале — нет.
Если команда по безопасности требует централизованный журнал — правильное решение: пишем локально, в той же транзакции, и в ней же кладём строку в таблицу исходящих сообщений (её обычно называют outbox). Отдельный фоновый процесс потом читает эту таблицу и отправляет записи наружу. Разница с AFTER_COMMIT принципиальная, хотя на первый взгляд похоже: там отправку пытаются сделать после коммита и при падении теряют, а здесь намерение отправить зафиксировано тем же коммитом, что и сама операция. Отправка случится рано или поздно — но не пропадёт.
Способ первый: Spring AOP аспект
Если администраторских действий много и они разбросаны по разным Handler-ам, удобно использовать аспект: помечаем методы аннотацией, аспект записывает журнал автоматически.
Сначала объявляем аннотацию-маркер:
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface AdminAction {
String action();
String resourceType();
String resourceId(); // выражение по аргументу метода: "#command.orderId()"
}
Идентификатор ресурса задаётся выражением, а не именем параметра: у обработчика параметр ровно один — сама команда, а нужный номер лежит внутри неё.
Затем сам аспект:
@Aspect
@Component
@RequiredArgsConstructor
@Order(Ordered.LOWEST_PRECEDENCE)
public class AdminAuditAspect {
private static final ExpressionParser PARSER = new SpelExpressionParser();
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 resourceId = extractResourceId(joinPoint, adminAction);
Object result;
try {
result = joinPoint.proceed();
} catch (Throwable failure) {
auditLogRepository.appendOutsideTransaction(record(user, adminAction, resourceId,
Map.of("outcome", "failed", "reason", failure.getClass().getSimpleName())));
throw failure;
}
auditLogRepository.append(record(user, adminAction, resourceId,
Map.of("outcome", "success")));
return result;
}
private String extractResourceId(ProceedingJoinPoint joinPoint, AdminAction adminAction) {
var context = new StandardEvaluationContext();
context.setVariable("command", joinPoint.getArgs()[0]);
var value = PARSER.parseExpression(adminAction.resourceId()).getValue(context);
return value != null ? value.toString() : null;
}
private AdminAuditRecord record(AuthenticatedUser user, AdminAction adminAction,
String resourceId, Map<String, Object> metadata) {
return AdminAuditRecord.builder()
.actorId(user.id().toString())
.action(adminAction.action())
.resourceType(adminAction.resourceType())
.resourceId(resourceId)
.occurredAt(Instant.now())
.metadata(metadata)
.requestId(MDC.get("requestId"))
.traceId(MDC.get("traceId"))
.build();
}
}
И использование в Handler-е:
@UseCase
@RequiredArgsConstructor
public class CancelOrderHandler implements UseCaseHandler<CancelOrderCommand, Order> {
@Override
@Transactional
@AdminAction(action = "cancel-order", resourceType = "Order", resourceId = "#command.orderId()")
public Order handle(CancelOrderCommand command) {
var order = orderRepository.findById(command.orderId()).orElseThrow();
order.cancel();
return orderRepository.save(order);
}
}
Теперь про @Order на аспекте — это не украшение, а единственное, что делает всю затею рабочей. По умолчанию и советник транзакций, и аспект без явного порядка получают одно и то же значение — самый низкий приоритет. При одинаковом порядке кто из них окажется снаружи, Spring не обещает. А от этого зависит всё: если аспект обернёт транзакционную обёртку, то к моменту auditLogRepository.append(...) транзакция уже зафиксирована, и запись уйдёт отдельной — ровно то, что ниже в статье названо неправильным. Поэтому порядок задают явно с обеих сторон: аспекту — самый низкий приоритет (он должен быть внутри), а советнику транзакций — приоритет повыше, чтобы он оказался снаружи:
@Configuration
@EnableTransactionManagement(order = Ordered.LOWEST_PRECEDENCE - 100)
public class TransactionConfig {
}
Меньшее число здесь означает «ближе к внешнему краю». Транзакция открывается первой и закрывается последней, аспект работает внутри неё — и запись в журнал попадает в тот же commit, что и отмена заказа.
Вторая деталь — неудачные попытки. Если обработчик бросил исключение (не хватило прав, бизнес-правило не пустило), транзакция откатится и унесёт с собой любую запись, сделанную внутри неё. Поэтому отказ пишут отдельной транзакцией: метод appendOutsideTransaction помечен @Transactional(propagation = Propagation.REQUIRES_NEW). Для разбора инцидента неудачные попытки администратора важны не меньше удавшихся — по ним видно, что кто-то пробовал.
Плюс подхода: аннотацию сложно забыть поставить, запись в журнал происходит автоматически. Минус виден прямо в коде: аспект знает только аргументы на входе и результат на выходе. Каким был статус заказа до отмены, он не видит — и metadata у него получается бедной. Как раз для этого случая есть второй способ.
Способ второй: явная запись в 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-а.
Итого: аспект — для типовых случаев, явный вызов — когда нужен специфический контекст. В обоих случаях запись идёт внутри транзакции операции: у аспекта это держится на @Order, у явного вызова — на том, что append вызван из того же @Transactional-метода.
Чтение тоже действие
Журнал выше ловит изменения: отмену, блокировку, возврат. Но самая частая претензия к работе с персональными данными звучит иначе: сотрудник открыл карточку клиента, которого не обслуживал, или выгрузил список на десять тысяч человек. Данные не изменились — в журнале нет ни строки.
Учёт таких обращений требуют и 152-ФЗ, и PCI DSS: кто и когда получал доступ к персональным и карточным данным. Значит, у административного чтения есть свой журнал, только правила другие: писать каждый GET нельзя, журнал станет больше самих данных, и читать его не будет никто.
Разумная граница выглядит так:
- Администратор открыл чужую карточку — пишем, одна строка на обращение,
action: "view-customer". - Выгрузка или список — пишем обязательно, с числом строк и условиями отбора: выгрузка на десять тысяч клиентов это событие, а не чтение.
- Клиент смотрит своё — не пишем, это обычная работа приложения.
- Служебные запросы своих сервисов — не пишем.
Технически это тот же аппендер, но не в той же транзакции: у чтения транзакции обычно нет. Запись делают отдельной, и здесь потеря строки при падении допустима — в отличие от изменений, где она означает несостоявшийся аудит. Зато у чтения своя беда: один открытый экран делает десяток запросов, и журнал заполняется дублями. Лечится тем, что пишут не запрос, а обращение: первое чтение карточки одним сотрудником в пределах минуты.
Рядом с журналом ставят счётчик: сколько чужих карточек сотрудник открыл за час. Он ловит злоупотребление, пока оно не стало выгрузкой всей базы, и это уже не журнал, а сигнал дежурному.
Дописывать можно, удалять нельзя
Таблица аудита — только для добавления. Ни администраторы приложения, ни само приложение не должны иметь возможности изменить или удалить уже записанные строки:
ALTER TABLE admin_audit_log OWNER TO audit_owner;
GRANT INSERT, SELECT ON admin_audit_log TO application_role;
REVOKE UPDATE, DELETE, TRUNCATE ON admin_audit_log FROM application_role;
Первая строка здесь не менее важна, чем последняя. Владелец таблицы в PostgreSQL может вернуть себе любые права одной командой — если таблица принадлежит той же роли, под которой ходит приложение, запрет превращается в вежливое напоминание. Поэтому владельцем делают отдельную роль, доступа к которой у приложения нет. И TRUNCATE отзывают отдельным словом: под DELETE он не подпадает, а вычищает таблицу целиком.
Если запись об администраторском действии можно удалить — смысл аудита теряется. Compliance-требования большинства отраслей предполагают хранение журнала от одного до семи лет.
Очистка старых записей — отдельная задача с привилегиями уровня DBA, не из кода приложения.
Кто что может делать с таблицей журнала: у приложения нет ни UPDATE, ни DELETE, ни владения, а просроченное убирает отдельная роль целой секцией.
Чего отзыв прав не закрывает
Отзыв прав у приложения защищает от приложения. Он не защищает от того, у кого есть доступ к самой базе: администратора базы данных, владельца таблицы, того, у кого есть резервная копия и право её восстановить. Для большинства сервисов этого достаточно — угроза «разработчик поправит журнал через код» закрыта. Но если журнал нужен как доказательство (деньги, регулятор, спор с клиентом), нужен ещё один шаг.
Внешний приёмник только на запись. Записи уходят туда, где ни у кого нет прав на изменение: хранилище объектов с блокировкой версий (Object Lock), журнальный сервис в другом контуре, специализированная система сбора событий безопасности. Ключевые слова — «другие администраторы»: если базой и приёмником управляет один человек, ничего не изменилось. Отправляют через таблицу исходящих сообщений, как описано выше, иначе часть записей потеряется.
Цепочка хешей. Каждая запись хранит хеш от своего содержимого вместе с хешем предыдущей: hash = sha256(prev_hash || payload). Поправить одну строку незаметно уже нельзя — придётся пересчитать всё до конца, а конец кто-то видел. Чтобы «кто-то видел» было правдой, последний хеш регулярно публикуют туда, где его не переписать, и отдельная задача раз в сутки пересчитывает цепочку и ругается на расхождение. Цена: записи становятся строго последовательными, параллельная вставка требует либо блокировки, либо одного потока записи.
Чего делать не стоит — считать защитой от администратора базы подпись записи ключом приложения. Ключ лежит там же, где приложение: кто дошёл до сервиса, дошёл и до ключа. Подпись защищает от подмены в пути, а не от того, у кого есть и база, и сервис.
Рост таблицы и срок хранения
Журнал — таблица, которая только растёт, и через год она обычно самая большая в базе. Сто административных действий в сутки это мелочь, но журнал чтений из раздела выше добавляет тысячи строк в день, а массовые операции — десятки тысяч за раз.
Секции по месяцам. Журнал идеально ложится на секционирование по времени: пишут всегда в текущую секцию, читают почти всегда за последние недели, удаляют целыми месяцами. PARTITION BY RANGE (occurred_at), секция на месяц, новые создаются заранее — планировщиком или pg_partman. Сам механизм разбирает статья про секционирование.
Как удалять то, что удалять нельзя. Здесь и появляется вопрос, который обычно повисает: DELETE у приложения отозван, а срок хранения — от одного года до семи лет, значит, просроченное всё-таки надо убирать. Разгадка в том, что убирает не приложение и не DELETE: просроченную секцию удаляет владелец таблицы командой DROP TABLE, отдельной ролью, по расписанию — и это само по себе действие, у которого есть запись в журнале. Без секций пришлось бы выдать кому-то право DELETE на журнал, то есть открыть ровно ту дверь, которую закрывали.
Срок берут не из головы. Он складывается из требований закона и отрасли, и у разных записей он разный: денежные операции хранят дольше, журнал чтений короче, технические записи ещё короче. Проще держать разные таблицы под разные сроки, чем выборочно чистить одну — выборочная чистка снова требует DELETE.
Помогает и то, что делается до всякого секционирования: не писать в журнал то, что уже есть в данных. Полная копия объекта в metadata на каждое изменение — самый быстрый способ сделать журнал вдвое больше базы.
Кто читает журнал
Журнал без читателя мёртв: он растёт, занимает место и создаёт ощущение защищённости. Поэтому у него, как у любого интерфейса, должен быть пользователь и сценарий.
Отдельная роль. Право читать журнал — не то же самое, что право администратора. У того, кто разбирает жалобы, оно есть; у того, чьи действия записаны, нет, иначе он видит, что о нём известно. Роль заводят явно (auditor) и не включают в состав административной.
Отдельный экран, а не запрос в базу. Если журнал читают запросом, читают его двое, и оба разработчики. Экран с фильтрами «по сотруднику», «по клиенту», «по действию», «за период» превращает журнал в инструмент поддержки и юриста. Он же ограничивает выгрузку: кнопка «скачать всё» — это новая утечка, теперь с историей всех действий сразу.
Чтение журнала — тоже запись в журнале. Аудитор ищет по конкретному клиенту, а значит видит, кто и что с ним делал; этот доступ фиксируют так же, как остальные.
И то, что делает журнал живым: регулярный взгляд без инцидента. Раз в месяц — короткий отчёт по числу административных действий, по обходам проверки владения, по выгрузкам. Если журнал не открывали год, к первому же инциденту выяснится, что половина действий пишется без причины, а половина не пишется вовсе.
Частые ошибки
Аудит написали, но без actor_id или occurred_at. Это делает журнал бесполезным — непонятно кто и когда.
Запись аудита вынесли за пределы транзакции — например, в @TransactionalEventListener с AFTER_COMMIT. Операция зафиксирована, но до записи в журнал приложение упало. Пропуск. Если журнал нужен ещё и во внешней системе — это делают через outbox в той же транзакции, а не отправкой после коммита.
Email администратора хранится открытым текстом в поле actor_email. Если журнал когда-нибудь утечёт — это лишние персональные данные. Лучше хранить только actor_id или хэш email.
Аудит только для «опасных» операций, а не для всех admin-действий. Что считается опасным — всегда субъективно. Правило проще: любое изменение данных от роли admin пишем в журнал.
Глубже: деньги: идемпотентность и правило четырёх глазрасширенное
Журнал отвечает на вопрос «кто это сделал». Для денежных операций этого мало: нужно, чтобы одно действие не выполнилось дважды и чтобы одного человека было недостаточно для крупного действия. Оба правила живут в коде обработчика команды, а не в интерфейсе.
Идемпотентность. Сеть повторяет запросы, клиент повторяет по таймауту, администратор нажимает дважды. Команда «вернуть деньги за заказ» без защиты вернёт их дважды. Ключ идемпотентности из статьи про заголовки REST здесь обязателен, и хранится он не в кэше, а в таблице рядом с результатом: (idempotency_key, actor_id) → status, response, в той же транзакции, что и сама операция. Повтор с тем же ключом получает сохранённый ответ, повтор с тем же ключом, но другим телом, отказ 422, а ключ, который «в работе», отвечает 409, чтобы две параллельные попытки не прошли обе. Для команд без ключа от клиента естественный ключ строят сами: возврат по заказу это refund:{orderId}, и второй возврат того же заказа упирается в уникальный индекс.
Четыре глаза. Возврат на сто рублей делает оператор; возврат на миллион требует второго человека. Это не флаг в интерфейсе, а состояние в модели: команда создаёт заявку PENDING_APPROVAL с суммой, инициатором и причиной, вторая команда approve переводит её в исполнение, и обработчик второй команды проверяет, что подтверждающий это не инициатор, if (approver.equals(request.requestedBy())) throw new SameActorException(), и что у него есть роль подтверждающего. Порог, с которого нужен второй, это настройка, а не константа, и её смена тоже административное действие с записью в журнал. Обе команды пишут в журнал из этой статьи, и по нему видно пару «кто просил, кто подтвердил».
Административный обход. «Суперадмин может всё без подтверждения» это дыра ровно того размера, что и его учётная запись. Обход, если он нужен для аварий, оформляют как отдельную команду с обязательной причиной, с записью в журнал и с оповещением второго человека постфактум; такой обход разрешают роли, которой нет ни у кого постоянно, а выдают на время инцидента.
Всё три правила проверяются тестами обработчика: повтор с тем же ключом не создаёт вторую запись, подтверждение инициатором отвергается, обход без причины отвергается. Это тесты на доменную логику, без Spring, и они дешевле любого разбора инцидента.
Коротко
- Каждое действие администратора, изменяющее данные, должно оставлять запись в журнале.
- Минимальный набор полей:
actor_id,occurred_at,action,resource_type,resource_id. Детали — вmetadataJSONB: состояние до и после, причина, номер обращения, от чьего имени, плюсrequest_idиtrace_idдля связки с трассировкой. - Два способа реализации:
@Around-аспект с аннотацией-маркером (DRY, меньше риск пропустить) или явная запись в Handler (больше контроля над метаданными). - Запись аудита — в той же транзакции и той же базе, что бизнес-операция. Иначе нет гарантии согласованности.
- Таблица append-only: только INSERT. Приложение лишается прав на UPDATE, DELETE и TRUNCATE — и не владеет таблицей, иначе вернёт права себе само.
- У аспекта порядок задают явно: без
@Orderон может оказаться снаружи транзакции. Неудачные попытки, наоборот, пишут отдельной транзакцией — откат бизнес-операции унесёт запись, сделанную внутри неё. - Деньги требуют больше журнала: ключ идемпотентности в таблице с результатом в той же транзакции (
409пока в работе,422на другое тело), правило четырёх глаз как состояние заявки с проверкой «подтверждающий не инициатор», аварийный обход отдельной командой с причиной и временной ролью. - Чтение чужих персональных данных фиксируют тоже: просмотр карточки и любую выгрузку с числом строк, но не каждый
GET. И у журнала есть читатель: рольauditorвне административной, экран с фильтрами вместо запроса в базу, ограниченная выгрузка. - Журнал растёт быстрее данных: секции по месяцам, просроченное убирает владелец через
DROPсекции, а не приложение черезDELETE. - Append-only защищает от приложения, но не от доступа к базе: доказательством журнал делает внешний приёмник только на запись или цепочка хешей с публикацией последнего значения.
Что почитать дальше
- ABAC: владение ресурсом — когда admin override запускает обязательный аудит.
- PII и секреты — что нельзя хранить в
metadataоткрытым текстом.