Опирается на правила: AUTH-15 из Auth Patterns Style Guide → раздел 6. Аудит admin-команд.

Важно знать

  • Каждая команда от роли admin, изменяющая state агрегата, обязана писать строку в *_audit_log таблицу.
  • Поля: actor_id (кто), occurred_at (когда), action (что), resource_type + resource_id (к чему), metadata JSONB (детали).
  • Реализация@Around-аспект на admin endpoints или явный вызов в Handler.
  • Та же БД, одна транзакция: либо бизнес-write + audit commit, либо оба rollback.
  • При ABAC override (admin отменил чужой заказ) — audit обязателен.
  • Audit append-only: только INSERT, никаких UPDATE/DELETE.
  • Без audit компрометация admin-аккаунта = невидимый ущерб.

Admin-роль обходит ABAC и имеет полный доступ. Это необходимое свойство для support, compliance, ops — но превращается в дыру без audit log. Каждое admin-действие должно быть восстановимо: кто, когда, что сделал, какой был state до и после.

Schema audit-таблицы

AUTH-15: одна таблица на сервис или per-aggregate.

Вариант 1 — единая таблица:

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

Вариант 2 — per-aggregate (order_audit_log, payment_audit_log) — когда схема различается и хочется типизированных колонок.

metadata JSONB — расширяемое поле для деталей: originalStatus, newStatus, reason, relatedIds. Не структурируем заранее, добавляем по необходимости.

Реализация — @Around-аспект

@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;
    }
}
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface AdminAction {
    String action();
    String resourceType();
    String resourceIdParam() default "id";
}
@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);
    }
}

@AdminAction помечает методы, @Around аспект автоматически пишет audit при admin-вызове.

Реализация — явный вызов

Альтернатива — без аспекта, явная запись в 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(), SelectMode.FOR_UPDATE).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-а. Преимущество аспекта — DRY, нельзя забыть.

Выбор: явный вызов когда метаданные сложные (нужен specific context до/после), аспект для типовых случаев.

Одна транзакция

Audit пишется в той же БД и той же транзакции, что бизнес-операция.

@Transactional начало
  order.cancel()
  orderRepository.save(order)
  auditLogRepository.append(...)
@Transactional commit

Что это даёт:

  • Если бизнес-операция откатилась — audit тоже откатился (нет false-positive).
  • Если бизнес commit — audit commit (нет false-negative).

Не выносим audit в отдельный сервис через Kafka — потеря событий между commit и publish даёт missing audit для real changes. Подробнее — Outbox publishing объясняет почему такой подход не работает.

Если в проекте есть отдельная audit-инфра (security team хочет centralized audit) — пишем дважды: локально (для consistency с бизнес-данными) + публикация через outbox в audit-стрим.

Append-only

Audit таблица — только INSERT. Никаких UPDATE/DELETE.

REVOKE UPDATE, DELETE ON admin_audit_log FROM application_role;

Если admin может удалить свою запись об admin-действии — audit бессмысленен. Compliance требует immutability.

Cleanup старых записей — отдельный admin tool с DBA-привилегиями, не из application. Retention обычно 1-7 лет в зависимости от индустрии.

Что запрещено

АнтипаттернПравилоЧто взамен
Admin override без auditAUTH-15audit обязателен
Audit в отдельную БД async (Kafka only)AUTH-15та же БД + outbox для дубликата
Audit без actor_id или occurred_atAUTH-15оба обязательны
UPDATE admin_audit_log SET ...AUTH-15append-only, REVOKE UPDATE/DELETE
Audit без trace_id / request_idAUTH-15для связки с distributed trace
Audit с PII в plain (admin_email)AUTH-16hash email, или хранить только actor_id
Audit пишется ПОСЛЕ @Transactional (after commit)AUTH-15внутри транзакции
Audit только для destructive actions, не readAUTH-15критичные reads (PII access) тоже

Куда дальше

  • Auth → раздел 6. Аудит admin-команд — нормативные формулировки.
  • ABAC: владение ресурсом — admin override как триггер audit.
  • PII и секреты — PII в audit metadata.
  • Observability → logging — traceId в audit для cross-ref.
  • Distributed → idempotency — audit идемпотентен (дубликат event = один audit).
  • PG schema*_audit_log partitioning по occurred_at.