feat(audit): полноценный writer в audit_log + admin UI (audit + outbox DLQ)

До этого audit_log entity была определена, но никто туда не писал — реальный
аудит шёл только через Hibernate Envers (без user/IP/trace). Теперь:

- AuditLogger service пишет в той же транзакции что и CRUD (DictionaryRecordService).
  Захватывает: user (JWT sub либо preferred_username), scope, IP (X-Forwarded-For),
  UA, trace_id (MDC), request_id. Не ломает основной flow при сбое — только
  громко логирует.
- AuditLogRepository: гибкий search() с nullable фильтрами (dictionary, user,
  action, businessKey, from, to) + Pageable.
- AuditAdminController: GET /admin/audit (paginated) + /admin/audit/export.csv
  (cap 10k строк против OOM).

Frontend:
- /audit route: фильтр-панель, таблица с цветными бейджами (CREATE/UPDATE/CLOSE),
  expand-row с JSON before/after diff, footer IP/UA/req_id, кнопка экспорта CSV.
  Pagination 50/100/200.
- /outbox route: stat-cards pending/DLQ + DLQ-таблица с last_error.
- Nav links + i18n (RU + EN).
This commit is contained in:
Zimin A.N.
2026-05-04 15:44:14 +03:00
parent e182b30c58
commit 7b2fe6f2d5
13 changed files with 1191 additions and 5 deletions
@@ -0,0 +1,135 @@
package cloud.nstart.terravault.ordinis.restapi.audit;
import cloud.nstart.terravault.ordinis.auth.ScopeContext;
import cloud.nstart.terravault.ordinis.domain.DataScope;
import cloud.nstart.terravault.ordinis.domain.audit.AuditLog;
import cloud.nstart.terravault.ordinis.domain.audit.AuditLogRepository;
import cloud.nstart.terravault.ordinis.domain.definition.DictionaryDefinition;
import cloud.nstart.terravault.ordinis.domain.record.DictionaryRecord;
import com.fasterxml.jackson.databind.JsonNode;
import jakarta.servlet.http.HttpServletRequest;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.slf4j.MDC;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationToken;
import org.springframework.stereotype.Service;
import org.springframework.web.context.request.RequestAttributes;
import org.springframework.web.context.request.RequestContextHolder;
import org.springframework.web.context.request.ServletRequestAttributes;
import java.time.OffsetDateTime;
import java.util.Set;
/**
* Запись в {@code audit_log} — юридически значимый лог. Источник истины для
* compliance, отдельно от технических логов в Loki.
*
* <p>Захватывает: user (sub claim из JWT либо "anonymous"), scope, IP, UA,
* trace_id из MDC (Micrometer Tracing). Вызывается из {@link
* cloud.nstart.terravault.ordinis.restapi.service.DictionaryRecordService} в
* той же транзакции что и change — at-least-once семантика.
*/
@Service
public class AuditLogger {
private static final Logger log = LoggerFactory.getLogger(AuditLogger.class);
private final AuditLogRepository repository;
private final ScopeContext scopeContext;
public AuditLogger(AuditLogRepository repository, ScopeContext scopeContext) {
this.repository = repository;
this.scopeContext = scopeContext;
}
public void recordCreate(DictionaryDefinition d, DictionaryRecord r) {
write("RECORD_CREATED", "CREATE", d, r, null, r.getData());
}
public void recordUpdate(
DictionaryDefinition d, DictionaryRecord r, JsonNode payloadBefore) {
write("RECORD_UPDATED", "UPDATE", d, r, payloadBefore, r.getData());
}
public void recordClose(DictionaryDefinition d, DictionaryRecord r, String reason) {
write("RECORD_CLOSED", "CLOSE", d, r, r.getData(), null);
}
private void write(
String eventType,
String action,
DictionaryDefinition d,
DictionaryRecord r,
JsonNode before,
JsonNode after) {
try {
AuditLog entry = new AuditLog();
entry.setEventTime(OffsetDateTime.now());
entry.setEventType(eventType);
entry.setAction(action);
entry.setDictionaryId(d.getId());
entry.setDictionaryName(d.getName());
entry.setRecordId(r.getId());
entry.setBusinessKey(r.getBusinessKey());
entry.setPayloadBefore(before);
entry.setPayloadAfter(after);
User user = currentUser();
entry.setUserId(user.id);
entry.setUserScope(user.scope);
RequestContext rc = currentRequest();
entry.setIpAddress(rc.ip);
entry.setUserAgent(rc.userAgent);
entry.setRequestId(rc.requestId);
entry.setTraceId(MDC.get("traceId"));
repository.save(entry);
} catch (Exception ex) {
// Аудит не должен ломать основной flow — но логируем громко чтобы
// алертилось. Compliance-кейс ловит на этой ошибке.
log.error(
"Failed to write audit_log entry: dictionary={} record_id={} action={}: {}",
d.getName(), r.getId(), action, ex.getMessage(), ex);
}
}
private User currentUser() {
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
if (auth instanceof JwtAuthenticationToken jwt) {
String sub = jwt.getToken().getSubject();
if (sub == null || sub.isBlank()) {
Object preferred = jwt.getToken().getClaim("preferred_username");
sub = preferred == null ? jwt.getName() : String.valueOf(preferred);
}
Set<DataScope> scopes = scopeContext.currentScopes();
DataScope effective = scopes.contains(DataScope.RESTRICTED) ? DataScope.RESTRICTED
: scopes.contains(DataScope.INTERNAL) ? DataScope.INTERNAL
: DataScope.PUBLIC;
return new User(sub, effective);
}
return new User("anonymous", DataScope.PUBLIC);
}
private RequestContext currentRequest() {
RequestAttributes attrs = RequestContextHolder.getRequestAttributes();
if (attrs instanceof ServletRequestAttributes sra) {
HttpServletRequest req = sra.getRequest();
String ip = headerOrRemote(req, "X-Forwarded-For");
if (ip != null && ip.contains(",")) ip = ip.split(",")[0].trim();
String requestId = req.getHeader("X-Request-Id");
return new RequestContext(ip, req.getHeader("User-Agent"), requestId);
}
return new RequestContext(null, null, null);
}
private static String headerOrRemote(HttpServletRequest req, String header) {
String v = req.getHeader(header);
return (v == null || v.isBlank()) ? req.getRemoteAddr() : v;
}
private record User(String id, DataScope scope) {}
private record RequestContext(String ip, String userAgent, String requestId) {}
}
@@ -8,6 +8,7 @@ import cloud.nstart.terravault.ordinis.events.record.DictionaryRecordCreated;
import cloud.nstart.terravault.ordinis.events.record.DictionaryRecordDeleted;
import cloud.nstart.terravault.ordinis.events.record.DictionaryRecordUpdated;
import cloud.nstart.terravault.ordinis.outbox.OutboxRecorder;
import cloud.nstart.terravault.ordinis.restapi.audit.AuditLogger;
import cloud.nstart.terravault.ordinis.restapi.dto.CreateRecordRequest;
import cloud.nstart.terravault.ordinis.restapi.error.OrdinisException;
import cloud.nstart.terravault.ordinis.validation.RecordValidator;
@@ -43,6 +44,7 @@ public class DictionaryRecordService {
private final DictionaryDefinitionService definitionService;
private final RecordValidator validator;
private final OutboxRecorder outbox;
private final AuditLogger auditLogger;
private static final OffsetDateTime FAR_FUTURE = OffsetDateTime.parse("9999-12-31T23:59:59Z");
@@ -50,11 +52,13 @@ public class DictionaryRecordService {
DictionaryRecordRepository repository,
DictionaryDefinitionService definitionService,
RecordValidator validator,
OutboxRecorder outbox) {
OutboxRecorder outbox,
AuditLogger auditLogger) {
this.repository = repository;
this.definitionService = definitionService;
this.validator = validator;
this.outbox = outbox;
this.auditLogger = auditLogger;
}
@Transactional(readOnly = true)
@@ -110,6 +114,7 @@ public class DictionaryRecordService {
}
throw ex;
}
auditLogger.recordCreate(d, saved);
publishCreated(d, saved);
return saved;
}
@@ -163,6 +168,7 @@ public class DictionaryRecordService {
throw ex;
}
auditLogger.recordUpdate(d, saved, prevData);
publishUpdated(d, saved, previousRecordId, prevData);
return saved;
}
@@ -180,6 +186,7 @@ public class DictionaryRecordService {
current.setValidTo(when);
repository.save(current);
auditLogger.recordClose(d, current, reason);
publishDeleted(d, current, when, reason);
}
@@ -0,0 +1,150 @@
package cloud.nstart.terravault.ordinis.restapi.web;
import cloud.nstart.terravault.ordinis.domain.audit.AuditLog;
import cloud.nstart.terravault.ordinis.domain.audit.AuditLogRepository;
import com.fasterxml.jackson.databind.JsonNode;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Sort;
import org.springframework.format.annotation.DateTimeFormat;
import org.springframework.http.MediaType;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import java.time.OffsetDateTime;
import java.util.UUID;
/**
* Admin endpoints для audit log. Read-only, фильтры опциональны.
*
* <ul>
* <li>{@code GET /admin/audit} — пагинированный поиск.</li>
* <li>{@code GET /admin/audit/export.csv} — выгрузка по тем же фильтрам
* (без пагинации; cap 10 000 строк против OOM).</li>
* </ul>
*/
@RestController
@RequestMapping("/api/v1/admin/audit")
public class AuditAdminController {
private static final int MAX_EXPORT_ROWS = 10_000;
private final AuditLogRepository repository;
public AuditAdminController(AuditLogRepository repository) {
this.repository = repository;
}
@GetMapping
public Page<AuditEntry> search(
@RequestParam(required = false) String dictionaryName,
@RequestParam(required = false) String userId,
@RequestParam(required = false) String action,
@RequestParam(required = false) String businessKey,
@RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) OffsetDateTime from,
@RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) OffsetDateTime to,
@RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "50") int size) {
Page<AuditLog> result = repository.search(
nullIfBlank(dictionaryName),
nullIfBlank(userId),
nullIfBlank(action),
nullIfBlank(businessKey),
from,
to,
PageRequest.of(page, Math.min(size, 200), Sort.by(Sort.Direction.DESC, "eventTime")));
return result.map(AuditEntry::from);
}
@GetMapping(value = "/export.csv", produces = "text/csv")
public org.springframework.http.ResponseEntity<String> exportCsv(
@RequestParam(required = false) String dictionaryName,
@RequestParam(required = false) String userId,
@RequestParam(required = false) String action,
@RequestParam(required = false) String businessKey,
@RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) OffsetDateTime from,
@RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) OffsetDateTime to) {
Page<AuditLog> result = repository.search(
nullIfBlank(dictionaryName),
nullIfBlank(userId),
nullIfBlank(action),
nullIfBlank(businessKey),
from,
to,
PageRequest.of(0, MAX_EXPORT_ROWS, Sort.by(Sort.Direction.DESC, "eventTime")));
StringBuilder csv = new StringBuilder(
"id,event_time,event_type,action,user_id,user_scope,dictionary_name,business_key,record_id,trace_id,ip_address\n");
for (AuditLog a : result.getContent()) {
csv.append(a.getId()).append(',')
.append(a.getEventTime()).append(',')
.append(csvCell(a.getEventType())).append(',')
.append(csvCell(a.getAction())).append(',')
.append(csvCell(a.getUserId())).append(',')
.append(a.getUserScope() == null ? "" : a.getUserScope().name()).append(',')
.append(csvCell(a.getDictionaryName())).append(',')
.append(csvCell(a.getBusinessKey())).append(',')
.append(a.getRecordId() == null ? "" : a.getRecordId()).append(',')
.append(csvCell(a.getTraceId())).append(',')
.append(csvCell(a.getIpAddress()))
.append('\n');
}
String filename = "audit-log-" + OffsetDateTime.now().toLocalDate() + ".csv";
return org.springframework.http.ResponseEntity.ok()
.header("Content-Disposition", "attachment; filename=\"" + filename + "\"")
.contentType(MediaType.parseMediaType("text/csv; charset=UTF-8"))
.body(csv.toString());
}
private static String nullIfBlank(String s) {
return (s == null || s.isBlank()) ? null : s;
}
private static String csvCell(String s) {
if (s == null) return "";
if (s.indexOf(',') < 0 && s.indexOf('"') < 0 && s.indexOf('\n') < 0) return s;
return "\"" + s.replace("\"", "\"\"") + "\"";
}
public record AuditEntry(
Long id,
OffsetDateTime eventTime,
String eventType,
String action,
String userId,
String userScope,
String dictionaryName,
UUID dictionaryId,
UUID recordId,
String businessKey,
JsonNode payloadBefore,
JsonNode payloadAfter,
String traceId,
String requestId,
String ipAddress,
String userAgent) {
static AuditEntry from(AuditLog a) {
return new AuditEntry(
a.getId(),
a.getEventTime(),
a.getEventType(),
a.getAction(),
a.getUserId(),
a.getUserScope() == null ? null : a.getUserScope().name(),
a.getDictionaryName(),
a.getDictionaryId(),
a.getRecordId(),
a.getBusinessKey(),
a.getPayloadBefore(),
a.getPayloadAfter(),
a.getTraceId(),
a.getRequestId(),
a.getIpAddress(),
a.getUserAgent());
}
}
}
@@ -38,7 +38,7 @@ class DictionaryRecordServiceTest {
repo = mock(DictionaryRecordRepository.class);
// resolveBusinessKey/enforceUniqueFields не дёргают остальные деп — передаём null,
// mocking concrete Spring services через Mockito-inline на JDK 25 ломается.
service = new DictionaryRecordService(repo, null, null, null);
service = new DictionaryRecordService(repo, null, null, null, null);
}
// ============= resolveBusinessKey =============