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

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

Главный вопрос здесь не «писать ли журнал», а где именно стоит строка записи — до фиксации транзакции или после неё.

одна admin-отмена заказа Order/42 — где стоит строка append(audit) штатный прогон: ничего не падает падение до COMMIT падение сразу после COMMIT A · append(audit) в той же транзакции order.cancel() save(order) append(audit) COMMIT ничего не пишем B · append(audit) после COMMIT order.cancel() save(order) ещё не пишем COMMIT append(audit) AFTER_COMMIT не выполнился действие 1 · запись в журнале 1 откат · действий 0 · записей 0 действие 1 · запись в журнале 1 откат · действий 0 · записей 0 действие 1 · записей 0 — пропуск пока ничего не падает — обе дорожки дают одинаковый журнал откат забрал и заказ, и строку аудита — ложных записей нет действие зафиксировано, а записи нет — журнал больше не полный

Дорожки расходятся ровно в одном такте — когда приложение падает сразу после 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), но для большинства случаев хватает одной.

строка журнала контекст запроса actor_id, request_id команда action, resource_id агрегат было и стало часы сервиса occurred_at admin_audit_log INSERT в той же транзакции

Откуда берётся каждое обязательное поле записи и куда она попадает: строка собирается из четырёх источников и уходит в таблицу тем же коммитом, что и сама операция.

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, не из кода приложения.

admin_audit_log application_role INSERT и SELECT auditor только SELECT audit_owner владелец, права любые роль очистки DROP старой секции

Кто что может делать с таблицей журнала: у приложения нет ни 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. Детали — в metadata JSONB: состояние до и после, причина, номер обращения, от чьего имени, плюс request_id и trace_id для связки с трассировкой.
  • Два способа реализации: @Around-аспект с аннотацией-маркером (DRY, меньше риск пропустить) или явная запись в Handler (больше контроля над метаданными).
  • Запись аудита — в той же транзакции и той же базе, что бизнес-операция. Иначе нет гарантии согласованности.
  • Таблица append-only: только INSERT. Приложение лишается прав на UPDATE, DELETE и TRUNCATE — и не владеет таблицей, иначе вернёт права себе само.
  • У аспекта порядок задают явно: без @Order он может оказаться снаружи транзакции. Неудачные попытки, наоборот, пишут отдельной транзакцией — откат бизнес-операции унесёт запись, сделанную внутри неё.
  • Деньги требуют больше журнала: ключ идемпотентности в таблице с результатом в той же транзакции (409 пока в работе, 422 на другое тело), правило четырёх глаз как состояние заявки с проверкой «подтверждающий не инициатор», аварийный обход отдельной командой с причиной и временной ролью.
  • Чтение чужих персональных данных фиксируют тоже: просмотр карточки и любую выгрузку с числом строк, но не каждый GET. И у журнала есть читатель: роль auditor вне административной, экран с фильтрами вместо запроса в базу, ограниченная выгрузка.
  • Журнал растёт быстрее данных: секции по месяцам, просроченное убирает владелец через DROP секции, а не приложение через DELETE.
  • Append-only защищает от приложения, но не от доступа к базе: доказательством журнал делает внешний приёмник только на запись или цепочка хешей с публикацией последнего значения.

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