Опирается на правила:
AUTH-15из Auth Patterns Style Guide → раздел 6. Аудит admin-команд.
Важно знать
- Каждая команда от роли
admin, изменяющая state агрегата, обязана писать строку в*_audit_logтаблицу.- Поля:
actor_id(кто),occurred_at(когда),action(что),resource_type+resource_id(к чему),metadataJSONB (детали).- Реализация —
@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 без audit | AUTH-15 | audit обязателен |
| Audit в отдельную БД async (Kafka only) | AUTH-15 | та же БД + outbox для дубликата |
Audit без actor_id или occurred_at | AUTH-15 | оба обязательны |
UPDATE admin_audit_log SET ... | AUTH-15 | append-only, REVOKE UPDATE/DELETE |
Audit без trace_id / request_id | AUTH-15 | для связки с distributed trace |
| Audit с PII в plain (admin_email) | AUTH-16 | hash email, или хранить только actor_id |
Audit пишется ПОСЛЕ @Transactional (after commit) | AUTH-15 | внутри транзакции |
| Audit только для destructive actions, не read | AUTH-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_logpartitioning поoccurred_at.