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:
+135
@@ -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
-1
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
+150
@@ -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());
|
||||
}
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -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 =============
|
||||
|
||||
Reference in New Issue
Block a user